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,165 @@
|
|
|
1
|
+
"""The Postgres types behind each column of the Zero schema, and the check that they agree.
|
|
2
|
+
|
|
3
|
+
Ported from rocicorp/mono `packages/zero-server/src/schema.ts` and `packages/zero-types/src/server-schema.ts`
|
|
4
|
+
at zero/v1.10.0 (905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from zero_server.schema import Schema
|
|
10
|
+
from zero_server.server.pg_types import zql_value_type
|
|
11
|
+
from zero_server.server.transaction import DBTransaction
|
|
12
|
+
from zero_server.shared.asserts import check
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ServerColumnSchema:
|
|
17
|
+
type: str
|
|
18
|
+
is_enum: bool
|
|
19
|
+
is_array: bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
type ServerTableSchema = dict[str, ServerColumnSchema]
|
|
23
|
+
type ServerSchema = dict[str, ServerTableSchema]
|
|
24
|
+
|
|
25
|
+
# Reads pg_catalog rather than information_schema, as upstream does, for the same columns.
|
|
26
|
+
_QUERY = """
|
|
27
|
+
SELECT
|
|
28
|
+
n.nspname::text AS schema,
|
|
29
|
+
cl.relname::text AS table,
|
|
30
|
+
a.attname::text AS column,
|
|
31
|
+
CASE
|
|
32
|
+
WHEN ut.typelem <> 0 AND ut.typlen = -1 THEN 'ARRAY'
|
|
33
|
+
WHEN nut.nspname = 'pg_catalog' THEN pg_catalog.format_type(ut.oid, NULL)
|
|
34
|
+
ELSE 'USER-DEFINED'
|
|
35
|
+
END::text AS "dataType",
|
|
36
|
+
CASE
|
|
37
|
+
WHEN tm.typmod = -1 THEN NULL
|
|
38
|
+
WHEN ut.oid IN ('pg_catalog.bpchar'::regtype, 'pg_catalog.varchar'::regtype) THEN tm.typmod - 4
|
|
39
|
+
WHEN ut.oid IN ('pg_catalog.bit'::regtype, 'pg_catalog.varbit'::regtype) THEN tm.typmod
|
|
40
|
+
END AS length,
|
|
41
|
+
CASE ut.oid
|
|
42
|
+
WHEN 'pg_catalog.int2'::regtype THEN 16
|
|
43
|
+
WHEN 'pg_catalog.int4'::regtype THEN 32
|
|
44
|
+
WHEN 'pg_catalog.int8'::regtype THEN 64
|
|
45
|
+
WHEN 'pg_catalog.float4'::regtype THEN 24
|
|
46
|
+
WHEN 'pg_catalog.float8'::regtype THEN 53
|
|
47
|
+
WHEN 'pg_catalog.numeric'::regtype THEN
|
|
48
|
+
CASE WHEN tm.typmod = -1 THEN NULL ELSE ((tm.typmod - 4) >> 16) & 65535 END
|
|
49
|
+
END AS precision,
|
|
50
|
+
CASE
|
|
51
|
+
WHEN ut.oid IN ('pg_catalog.int2'::regtype, 'pg_catalog.int4'::regtype, 'pg_catalog.int8'::regtype)
|
|
52
|
+
THEN 0
|
|
53
|
+
WHEN ut.oid = 'pg_catalog.numeric'::regtype AND tm.typmod <> -1 THEN (tm.typmod - 4) & 65535
|
|
54
|
+
END AS scale,
|
|
55
|
+
ut.typtype::text AS typtype,
|
|
56
|
+
ut.typname::text AS typename,
|
|
57
|
+
CASE WHEN ut.typelem <> 0 THEN et.typtype::text ELSE NULL END AS "elemTyptype",
|
|
58
|
+
CASE WHEN ut.typelem <> 0 THEN et.typname::text ELSE NULL END AS "elemTypname"
|
|
59
|
+
FROM
|
|
60
|
+
pg_catalog.pg_attribute a
|
|
61
|
+
JOIN pg_catalog.pg_class cl ON cl.oid = a.attrelid
|
|
62
|
+
JOIN pg_catalog.pg_namespace n ON n.oid = cl.relnamespace
|
|
63
|
+
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
|
64
|
+
JOIN pg_catalog.pg_type ut ON ut.oid = CASE WHEN t.typtype = 'd' THEN t.typbasetype ELSE t.oid END
|
|
65
|
+
JOIN pg_catalog.pg_namespace nut ON nut.oid = ut.typnamespace
|
|
66
|
+
LEFT JOIN pg_catalog.pg_type et ON et.oid = ut.typelem
|
|
67
|
+
CROSS JOIN LATERAL
|
|
68
|
+
(SELECT CASE WHEN t.typtype = 'd' THEN t.typtypmod ELSE a.atttypmod END AS typmod) tm
|
|
69
|
+
WHERE
|
|
70
|
+
a.attnum > 0
|
|
71
|
+
AND NOT a.attisdropped
|
|
72
|
+
AND cl.relkind IN ('r', 'v', 'f', 'p')
|
|
73
|
+
AND NOT pg_catalog.pg_is_other_temp_schema(n.oid)
|
|
74
|
+
AND (pg_catalog.pg_has_role(cl.relowner, 'USAGE')
|
|
75
|
+
OR pg_catalog.has_column_privilege(cl.oid, a.attnum, 'SELECT, INSERT, UPDATE, REFERENCES'))
|
|
76
|
+
AND (n.nspname, cl.relname) IN ({pairs})
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
_ENUM = "e"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _schema_and_table(name: str, server_name: str | None) -> tuple[str, str]:
|
|
83
|
+
if server_name and "." in server_name:
|
|
84
|
+
schema, table = server_name.split(".", 1)
|
|
85
|
+
return schema, table
|
|
86
|
+
return "public", server_name or name
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def get_server_schema(tx: DBTransaction[object], schema: Schema) -> ServerSchema:
|
|
90
|
+
pairs = [_schema_and_table(t.name, t.server_name) for t in schema.tables.values()]
|
|
91
|
+
if not pairs:
|
|
92
|
+
return {}
|
|
93
|
+
placeholders = ",".join(f"(${2 * i + 1}::text, ${2 * i + 2}::text)" for i in range(len(pairs)))
|
|
94
|
+
rows = await tx.query(_QUERY.format(pairs=placeholders), [part for pair in pairs for part in pair])
|
|
95
|
+
|
|
96
|
+
server_schema: ServerSchema = {}
|
|
97
|
+
for row in rows:
|
|
98
|
+
table_name = row["table"] if row["schema"] == "public" else f"{row['schema']}.{row['table']}"
|
|
99
|
+
table = server_schema.setdefault(table_name, {})
|
|
100
|
+
is_array = row["elemTyptype"] is not None
|
|
101
|
+
is_enum = (row["elemTyptype"] if row["elemTyptype"] is not None else row["typtype"]) == _ENUM
|
|
102
|
+
type_ = row["dataType"].lower()
|
|
103
|
+
if is_array:
|
|
104
|
+
check(
|
|
105
|
+
row["elemTypname"],
|
|
106
|
+
f'Array column "{row["column"]}" in table "{table_name}" is missing element type name',
|
|
107
|
+
)
|
|
108
|
+
type_ = row["elemTypname"]
|
|
109
|
+
elif is_enum or (type_ == "user-defined" and zql_value_type(row["typename"], False, False) is not None):
|
|
110
|
+
type_ = row["typename"]
|
|
111
|
+
if type_ in ("bpchar", "character", "character varying", "varchar") and row["length"]:
|
|
112
|
+
type_ = f"{type_}({row['length']})"
|
|
113
|
+
if type_ in ("numeric", "decimal") and row["precision"] is not None and row["scale"] is not None:
|
|
114
|
+
type_ = f"{type_}({row['precision']}, {row['scale']})"
|
|
115
|
+
table[row["column"]] = ServerColumnSchema(type=type_, is_enum=is_enum, is_array=is_array)
|
|
116
|
+
|
|
117
|
+
errors = check_schemas_are_compatible(schema, server_schema)
|
|
118
|
+
check(not errors, _incompatible_message(errors))
|
|
119
|
+
return server_schema
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def check_schemas_are_compatible(schema: Schema, server_schema: ServerSchema) -> list[str]:
|
|
123
|
+
"""One message per table, column or type in `schema` that the database does not match."""
|
|
124
|
+
errors: list[str] = []
|
|
125
|
+
for table in schema.tables.values():
|
|
126
|
+
server_table_name = table.server_name or table.name
|
|
127
|
+
server_table = server_schema.get(server_table_name)
|
|
128
|
+
if server_table is None:
|
|
129
|
+
errors.append(
|
|
130
|
+
f'Table "{server_table_name}" is defined in your zero schema but does not exist in the database.'
|
|
131
|
+
)
|
|
132
|
+
continue
|
|
133
|
+
for column_name, column in table.columns.items():
|
|
134
|
+
server_column_name = column.server_name or column_name
|
|
135
|
+
server_column = server_table.get(server_column_name)
|
|
136
|
+
if server_column is None:
|
|
137
|
+
errors.append(
|
|
138
|
+
f'Column "{server_column_name}" in table "{server_table_name}" is defined in your zero schema '
|
|
139
|
+
"but does not exist in the database."
|
|
140
|
+
)
|
|
141
|
+
continue
|
|
142
|
+
required = zql_value_type(server_column.type, server_column.is_enum, server_column.is_array)
|
|
143
|
+
if required != column.type:
|
|
144
|
+
reason = (
|
|
145
|
+
f"{server_column.type} is currently unsupported in Zero. Please file a bug at "
|
|
146
|
+
"https://bugs.rocicorp.dev/"
|
|
147
|
+
if required is None
|
|
148
|
+
else f"{server_column.type} should be mapped to {required} in Zero not {column.type}."
|
|
149
|
+
)
|
|
150
|
+
errors.append(
|
|
151
|
+
f'Type mismatch for column "{server_column_name}" in table "{server_table_name}": {reason}'
|
|
152
|
+
)
|
|
153
|
+
return errors
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _incompatible_message(errors: list[str]) -> str:
|
|
157
|
+
return "\n".join(
|
|
158
|
+
[
|
|
159
|
+
"Schema incompatibility detected between your zero schema definition and the database:",
|
|
160
|
+
"",
|
|
161
|
+
*(f" - {e}" for e in errors),
|
|
162
|
+
"",
|
|
163
|
+
"Please update your schema definition to match the database or migrate your database to match the schema.",
|
|
164
|
+
]
|
|
165
|
+
)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""The transaction a server mutator receives.
|
|
2
|
+
|
|
3
|
+
Ported from rocicorp/mono `packages/zero-server/src/custom.ts` (`TransactionImpl`) and
|
|
4
|
+
`packages/zql/src/mutate/custom.ts` at zero/v1.10.0 (905d08516ba0caaf314cd28e93a5a47e8acdd482).
|
|
5
|
+
Apache-2.0, Copyright Rocicorp.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, overload
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from zero_server.query.builder import OneQuery, Query
|
|
13
|
+
from zero_server.schema import Schema
|
|
14
|
+
from zero_server.server.crud import Mutate
|
|
15
|
+
from zero_server.server.server_schema import ServerSchema
|
|
16
|
+
|
|
17
|
+
type Row = dict[str, Any]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DBTransaction[W](Protocol):
|
|
21
|
+
"""One open database transaction. `query` takes SQL with `$1`-style placeholders, as upstream's does."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def wrapped_transaction(self) -> W: ...
|
|
25
|
+
|
|
26
|
+
async def query(self, sql: str, params: Sequence[Any] = ()) -> list[Row]: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ServerTransaction[W]:
|
|
30
|
+
location: Literal["server"] = "server"
|
|
31
|
+
reason: Literal["authoritative"] = "authoritative"
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
db_transaction: DBTransaction[W],
|
|
36
|
+
client_id: str,
|
|
37
|
+
mutation_id: int,
|
|
38
|
+
schema: "Schema | None" = None,
|
|
39
|
+
server_schema: "ServerSchema | None" = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
self.db_transaction = db_transaction
|
|
42
|
+
self.client_id = client_id
|
|
43
|
+
self.mutation_id = mutation_id
|
|
44
|
+
self._schema = schema
|
|
45
|
+
self._server_schema = server_schema
|
|
46
|
+
|
|
47
|
+
def _schemas(self, what: str) -> "tuple[Schema, ServerSchema]":
|
|
48
|
+
if self._schema is None or self._server_schema is None:
|
|
49
|
+
raise RuntimeError(f"{what} needs a schema: pass one to the database")
|
|
50
|
+
return self._schema, self._server_schema
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def mutate(self) -> "Mutate":
|
|
54
|
+
"""CRUD on the schema's tables, in client names."""
|
|
55
|
+
from zero_server.server.crud import Mutate
|
|
56
|
+
|
|
57
|
+
schema, server_schema = self._schemas("tx.mutate")
|
|
58
|
+
return Mutate(schema, self.db_transaction, server_schema)
|
|
59
|
+
|
|
60
|
+
@overload
|
|
61
|
+
async def run[R](self, query: "OneQuery[R, Any]") -> R | None: ...
|
|
62
|
+
@overload
|
|
63
|
+
async def run[R](self, query: "Query[R, Any]") -> list[R]: ...
|
|
64
|
+
async def run(self, query: "Query[Any, Any]") -> Any:
|
|
65
|
+
"""Run a ZQL query inside this transaction, against Postgres: a list of rows, or for a `one()`
|
|
66
|
+
query the row or `None`. Relationships come back nested under each row, under their names."""
|
|
67
|
+
from zero_server.server.pg_query_executor import execute_postgres_query
|
|
68
|
+
|
|
69
|
+
schema, server_schema = self._schemas("tx.run")
|
|
70
|
+
return await execute_postgres_query(self.db_transaction, query.ast, query.format, schema, server_schema)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""A transaction provider over any Postgres driver, with `tx.mutate` and `tx.run` for mutators.
|
|
2
|
+
|
|
3
|
+
Ported from rocicorp/mono `packages/zero-server/src/zql-database.ts` at zero/v1.10.0
|
|
4
|
+
(905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
|
|
5
|
+
|
|
6
|
+
A driver plugs in as a `DBConnection`: something that opens a transaction and hands back a
|
|
7
|
+
`DBTransaction` whose `query` takes `$1`-style placeholders. `zero_server.server.adapters` has one per
|
|
8
|
+
supported driver.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Awaitable, Callable
|
|
12
|
+
from typing import Any, Protocol, overload
|
|
13
|
+
|
|
14
|
+
from zero_server.protocol.mutation import CleanupResultsArg, CleanupResultsBulkArg, MutationResponse
|
|
15
|
+
from zero_server.query.builder import OneQuery, Query
|
|
16
|
+
from zero_server.schema import Schema
|
|
17
|
+
from zero_server.server.process_mutations import TransactionProviderHooks, TransactionProviderInput
|
|
18
|
+
from zero_server.server.server_schema import ServerSchema, get_server_schema
|
|
19
|
+
from zero_server.server.transaction import DBTransaction, ServerTransaction
|
|
20
|
+
from zero_server.shared.js import js_stringify
|
|
21
|
+
from zero_server.z2s.sql import ident
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PostgresError(Exception):
|
|
25
|
+
"""A statement Postgres refused, carrying its primary message.
|
|
26
|
+
|
|
27
|
+
Named as the postgres.js adapter names its errors upstream, so the error a client sees for a refused
|
|
28
|
+
statement is the same whichever server ran it.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str) -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.message = message
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DBConnection[W](Protocol):
|
|
37
|
+
"""Opens one transaction, runs `fn` in it, and commits when `fn` returns or rolls back when it raises."""
|
|
38
|
+
|
|
39
|
+
async def transaction[R](self, fn: Callable[[DBTransaction[W]], Awaitable[R]]) -> R: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Hooks:
|
|
43
|
+
def __init__(self, tx: DBTransaction[Any], transaction_input: TransactionProviderInput) -> None:
|
|
44
|
+
self._tx = tx
|
|
45
|
+
self._input = transaction_input
|
|
46
|
+
self._schema = ident(transaction_input.upstream_schema)
|
|
47
|
+
|
|
48
|
+
async def update_client_mutation_id(self) -> int:
|
|
49
|
+
rows = await self._tx.query(
|
|
50
|
+
f"""INSERT INTO {self._schema}.clients
|
|
51
|
+
as current ("clientGroupID", "clientID", "lastMutationID")
|
|
52
|
+
VALUES ($1, $2, $3)
|
|
53
|
+
ON CONFLICT ("clientGroupID", "clientID")
|
|
54
|
+
DO UPDATE SET "lastMutationID" = current."lastMutationID" + 1
|
|
55
|
+
RETURNING "lastMutationID\"""",
|
|
56
|
+
(self._input.client_group_id, self._input.client_id, 1),
|
|
57
|
+
)
|
|
58
|
+
return int(rows[0]["lastMutationID"])
|
|
59
|
+
|
|
60
|
+
async def write_mutation_result(self, result: MutationResponse) -> None:
|
|
61
|
+
await self._tx.query(
|
|
62
|
+
f"""INSERT INTO {self._schema}.mutations
|
|
63
|
+
("clientGroupID", "clientID", "mutationID", "result")
|
|
64
|
+
VALUES ($1, $2, $3, $4::text::json)""",
|
|
65
|
+
(
|
|
66
|
+
self._input.client_group_id,
|
|
67
|
+
result.id.client_id,
|
|
68
|
+
result.id.id,
|
|
69
|
+
js_stringify(result.result.to_json()),
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def delete_mutation_results(self, args: CleanupResultsArg) -> None:
|
|
74
|
+
if isinstance(args, CleanupResultsBulkArg):
|
|
75
|
+
await self._tx.query(
|
|
76
|
+
f"""DELETE FROM {self._schema}."mutations"
|
|
77
|
+
WHERE "clientGroupID" = $1 AND "clientID" = ANY($2)""",
|
|
78
|
+
(args.client_group_id, list(args.client_ids)),
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
await self._tx.query(
|
|
82
|
+
f"""DELETE FROM {self._schema}."mutations"
|
|
83
|
+
WHERE "clientGroupID" = $1 AND "clientID" = $2 AND "mutationID" <= $3""",
|
|
84
|
+
(args.client_group_id, args.client_id, args.up_to_mutation_id),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ZQLDatabase[W]:
|
|
89
|
+
"""Runs each mutation in one transaction of `connection`.
|
|
90
|
+
|
|
91
|
+
With a `schema`, mutators get `tx.mutate` and `tx.run`, and the first transaction checks the schema
|
|
92
|
+
against the database's columns.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, connection: DBConnection[W], schema: Schema | None = None) -> None:
|
|
96
|
+
self.connection = connection
|
|
97
|
+
self.schema = schema
|
|
98
|
+
self._server_schema: ServerSchema | None = None
|
|
99
|
+
|
|
100
|
+
async def transaction[R](
|
|
101
|
+
self,
|
|
102
|
+
callback: Callable[[ServerTransaction[W], TransactionProviderHooks], Awaitable[R]],
|
|
103
|
+
transaction_input: TransactionProviderInput | None = None,
|
|
104
|
+
) -> R:
|
|
105
|
+
transaction_input = transaction_input or TransactionProviderInput(
|
|
106
|
+
upstream_schema="", client_group_id="", client_id="", mutation_id=0
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def run(tx: DBTransaction[W]) -> R:
|
|
110
|
+
if self.schema is not None and self._server_schema is None:
|
|
111
|
+
self._server_schema = await get_server_schema(tx, self.schema)
|
|
112
|
+
server_tx = ServerTransaction(
|
|
113
|
+
tx, transaction_input.client_id, int(transaction_input.mutation_id), self.schema, self._server_schema
|
|
114
|
+
)
|
|
115
|
+
return await callback(server_tx, _Hooks(tx, transaction_input))
|
|
116
|
+
|
|
117
|
+
return await self.connection.transaction(run)
|
|
118
|
+
|
|
119
|
+
@overload
|
|
120
|
+
async def run[R](self, query: OneQuery[R, Any]) -> R | None: ...
|
|
121
|
+
@overload
|
|
122
|
+
async def run[R](self, query: Query[R, Any]) -> list[R]: ...
|
|
123
|
+
async def run(self, query: Query[Any, Any]) -> Any:
|
|
124
|
+
"""Run a ZQL query in a transaction of its own; see `ServerTransaction.run`."""
|
|
125
|
+
|
|
126
|
+
async def run(tx: ServerTransaction[W], _hooks: TransactionProviderHooks) -> Any:
|
|
127
|
+
return await tx.run(query)
|
|
128
|
+
|
|
129
|
+
return await self.transaction(run)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Helpers shared across the port, from rocicorp/mono `packages/shared`."""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Upstream's `assert`, which, unlike Python's, survives `python -O`.
|
|
2
|
+
|
|
3
|
+
Ported from rocicorp/mono `packages/shared/src/asserts.ts` at zero/v1.10.0
|
|
4
|
+
(905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any, TypeGuard
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AssertionFailedError(Exception):
|
|
11
|
+
"""Upstream throws a plain `Error` here, so it reports no `name`."""
|
|
12
|
+
|
|
13
|
+
error_name = "Error"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def check(condition: Any, message: str) -> TypeGuard[Any]:
|
|
17
|
+
if not condition:
|
|
18
|
+
raise AssertionFailedError(message)
|
|
19
|
+
return True
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Reading a message and JSON details off anything that was raised.
|
|
2
|
+
|
|
3
|
+
Ported from rocicorp/mono `packages/shared/src/error.ts` at zero/v1.10.0
|
|
4
|
+
(905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
|
|
5
|
+
|
|
6
|
+
JavaScript's `Error` is Python's `Exception`: an error whose class is exactly `Exception` carries no
|
|
7
|
+
`{name}` detail, the way a plain `Error` carries none upstream. A class can set `error_name` to the name
|
|
8
|
+
its upstream counterpart reports, which is `"Error"` for the upstream classes that never set `this.name`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from zero_server.shared.js import js_stringify
|
|
14
|
+
from zero_server.shared.json import JSONValue, is_json_value
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_error_message(error: object) -> str:
|
|
18
|
+
return _message(error, set())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _message(error: object, seen: set[int]) -> str:
|
|
22
|
+
if isinstance(error, str):
|
|
23
|
+
return error
|
|
24
|
+
if id(error) in seen:
|
|
25
|
+
return "Circular error reference detected while extracting the error message."
|
|
26
|
+
seen.add(id(error))
|
|
27
|
+
|
|
28
|
+
if isinstance(error, BaseException):
|
|
29
|
+
message = _exception_message(error)
|
|
30
|
+
if message:
|
|
31
|
+
return message
|
|
32
|
+
if error.__cause__ is not None:
|
|
33
|
+
return _message(error.__cause__, seen)
|
|
34
|
+
|
|
35
|
+
if isinstance(error, dict):
|
|
36
|
+
message = error.get("message")
|
|
37
|
+
if isinstance(message, str) and message:
|
|
38
|
+
return message
|
|
39
|
+
|
|
40
|
+
as_object = enumerable_properties(error) if isinstance(error, BaseException) else error
|
|
41
|
+
if is_json_value(as_object):
|
|
42
|
+
return f"Parsed message: {js_stringify(as_object)}"
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
f"Unknown error of type {js_typeof(error)} was thrown and the message could not be determined. "
|
|
46
|
+
"See cause for details."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def enumerable_properties(error: BaseException) -> dict[str, Any]:
|
|
51
|
+
"""What `JSON.stringify` would see of an error: its own attributes, less those JavaScript hides.
|
|
52
|
+
|
|
53
|
+
`message` is never enumerable on a JavaScript error, and `ApplicationError` keeps its details in a
|
|
54
|
+
private field.
|
|
55
|
+
"""
|
|
56
|
+
hidden = {"message", "details"} if type(error).__name__ == "ApplicationError" else {"message"}
|
|
57
|
+
return {k: v for k, v in vars(error).items() if not k.startswith("_") and k not in hidden}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def js_typeof(value: object) -> str:
|
|
61
|
+
"""`typeof` for what reaches the unknown-error path, which is never a JSON value."""
|
|
62
|
+
return "function" if callable(value) else "object"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _exception_message(error: BaseException) -> str:
|
|
66
|
+
message = getattr(error, "message", None)
|
|
67
|
+
if isinstance(message, str):
|
|
68
|
+
return message
|
|
69
|
+
if len(error.args) == 1 and isinstance(error.args[0], str):
|
|
70
|
+
return error.args[0]
|
|
71
|
+
return str(error)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_error_details(error: object) -> JSONValue | None:
|
|
75
|
+
if isinstance(error, BaseException):
|
|
76
|
+
details: Any = getattr(error, "details", None)
|
|
77
|
+
if details is not None and is_json_value(details):
|
|
78
|
+
return details
|
|
79
|
+
name = error_name(error)
|
|
80
|
+
if name not in ("Exception", "Error"):
|
|
81
|
+
return {"name": name}
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
if isinstance(error, dict) and "details" in error:
|
|
85
|
+
details = error["details"]
|
|
86
|
+
if is_json_value(details):
|
|
87
|
+
return details
|
|
88
|
+
|
|
89
|
+
if is_json_value(error):
|
|
90
|
+
return error # ty: ignore[invalid-return-type]
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def error_name(error: BaseException) -> str:
|
|
95
|
+
"""The `name` upstream would report for this error."""
|
|
96
|
+
name = getattr(type(error), "error_name", None)
|
|
97
|
+
return name if isinstance(name, str) else type(error).__name__
|
zero_server/shared/js.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""JavaScript's `String(value)`, for the places upstream compares stringified values."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from functools import cmp_to_key
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def js_string(value: object) -> str:
|
|
10
|
+
match value:
|
|
11
|
+
case None:
|
|
12
|
+
return "null"
|
|
13
|
+
case bool():
|
|
14
|
+
return "true" if value else "false"
|
|
15
|
+
case int() | float():
|
|
16
|
+
return js_number(value)
|
|
17
|
+
case list() | tuple():
|
|
18
|
+
return ",".join("" if item is None else js_string(item) for item in value)
|
|
19
|
+
case dict():
|
|
20
|
+
return "[object Object]"
|
|
21
|
+
case _:
|
|
22
|
+
return str(value)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def js_number(value: float) -> str:
|
|
26
|
+
"""`Number.prototype.toString()`: the shortest round-trip digits, laid out by ECMA-262's rules."""
|
|
27
|
+
if value == 0:
|
|
28
|
+
return "0"
|
|
29
|
+
if value < 0:
|
|
30
|
+
return "-" + js_number(-value)
|
|
31
|
+
_sign, digit_tuple, exponent = Decimal(repr(float(value))).as_tuple()
|
|
32
|
+
assert isinstance(exponent, int)
|
|
33
|
+
digits = "".join(map(str, digit_tuple)).rstrip("0")
|
|
34
|
+
exponent += len(digit_tuple) - len(digits)
|
|
35
|
+
k = len(digits)
|
|
36
|
+
n = k + exponent
|
|
37
|
+
if k <= n <= 21:
|
|
38
|
+
return digits + "0" * (n - k)
|
|
39
|
+
if 0 < n <= 21:
|
|
40
|
+
return digits[:n] + "." + digits[n:]
|
|
41
|
+
if -6 < n <= 0:
|
|
42
|
+
return "0." + "0" * -n + digits
|
|
43
|
+
e = n - 1
|
|
44
|
+
exp = f"e{'+' if e >= 0 else '-'}{abs(e)}"
|
|
45
|
+
return digits + exp if k == 1 else digits[0] + "." + digits[1:] + exp
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def js_sort[T](items: list[T], compare: Callable[[T, T], int]) -> list[T]:
|
|
49
|
+
"""`items.sort(compare)` as V8 runs it, returning a new list.
|
|
50
|
+
|
|
51
|
+
Upstream's `cmpCondition` is not a consistent comparator (a compound condition and a subquery each
|
|
52
|
+
sort before the other), so the result depends on the exact comparisons the sort makes. V8 sorts an
|
|
53
|
+
array shorter than 64 by finding the leading run and binary-inserting the rest, which this repeats
|
|
54
|
+
comparison for comparison. Longer arrays fall back to Python's sort, which is the same TimSort.
|
|
55
|
+
"""
|
|
56
|
+
out = list(items)
|
|
57
|
+
n = len(out)
|
|
58
|
+
if n < 2:
|
|
59
|
+
return out
|
|
60
|
+
if n >= 64:
|
|
61
|
+
return sorted(out, key=cmp_to_key(compare))
|
|
62
|
+
run = 2
|
|
63
|
+
descending = compare(out[1], out[0]) < 0
|
|
64
|
+
while run < n:
|
|
65
|
+
order = compare(out[run], out[run - 1])
|
|
66
|
+
if (order < 0) != descending:
|
|
67
|
+
break
|
|
68
|
+
run += 1
|
|
69
|
+
if descending:
|
|
70
|
+
out[:run] = reversed(out[:run])
|
|
71
|
+
for start in range(run, n):
|
|
72
|
+
pivot = out[start]
|
|
73
|
+
left, right = 0, start
|
|
74
|
+
while left < right:
|
|
75
|
+
mid = left + ((right - left) >> 1)
|
|
76
|
+
if compare(pivot, out[mid]) < 0:
|
|
77
|
+
right = mid
|
|
78
|
+
else:
|
|
79
|
+
left = mid + 1
|
|
80
|
+
out[left + 1 : start + 1] = out[left:start]
|
|
81
|
+
out[left] = pivot
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def js_truthy(value: object) -> bool:
|
|
86
|
+
"""JavaScript's truthiness for a JSON value: an empty array or object is truthy there."""
|
|
87
|
+
if value is None or value is False or value == "":
|
|
88
|
+
return False
|
|
89
|
+
if isinstance(value, int | float) and not isinstance(value, bool):
|
|
90
|
+
return value == value and value != 0 # NaN is falsy
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def js_stringify(value: object) -> str:
|
|
95
|
+
"""`JSON.stringify(value)` for a JSON value: compact, numbers written as JavaScript writes them."""
|
|
96
|
+
match value:
|
|
97
|
+
case None:
|
|
98
|
+
return "null"
|
|
99
|
+
case bool():
|
|
100
|
+
return "true" if value else "false"
|
|
101
|
+
case int() | float():
|
|
102
|
+
return js_number(value)
|
|
103
|
+
case str():
|
|
104
|
+
return json.dumps(value, ensure_ascii=False)
|
|
105
|
+
case list() | tuple():
|
|
106
|
+
return "[" + ",".join(js_stringify(item) for item in value) + "]"
|
|
107
|
+
case dict():
|
|
108
|
+
members = (f"{json.dumps(k, ensure_ascii=False)}:{js_stringify(value[k])}" for k in js_keys(value))
|
|
109
|
+
return "{" + ",".join(members) + "}"
|
|
110
|
+
case _:
|
|
111
|
+
raise TypeError(f"not a JSON value: {type(value).__name__}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _is_array_index(key: str) -> bool:
|
|
115
|
+
return key.isascii() and key.isdigit() and (key == "0" or key[0] != "0") and int(key) < 2**32 - 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def js_keys(obj: dict[str, object]) -> list[str]:
|
|
119
|
+
"""`Object.keys(obj)`: array-index keys first in numeric order, then the rest as inserted."""
|
|
120
|
+
indexes = sorted((k for k in obj if _is_array_index(k)), key=int)
|
|
121
|
+
return indexes + [k for k in obj if not _is_array_index(k)]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""JSON values, as Zero's wire format allows them.
|
|
2
|
+
|
|
3
|
+
Ported from rocicorp/mono `packages/shared/src/json.ts` at zero/v1.10.0
|
|
4
|
+
(905d08516ba0caaf314cd28e93a5a47e8acdd482). Apache-2.0, Copyright Rocicorp.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import JsonValue
|
|
11
|
+
|
|
12
|
+
type JSONValue = JsonValue
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_json_value(value: Any) -> bool:
|
|
16
|
+
"""Whether `value` survives `JSON.stringify` unchanged."""
|
|
17
|
+
match value:
|
|
18
|
+
case None | bool() | int() | str():
|
|
19
|
+
return True
|
|
20
|
+
case float():
|
|
21
|
+
return math.isfinite(value)
|
|
22
|
+
case list() | tuple():
|
|
23
|
+
return all(is_json_value(item) for item in value)
|
|
24
|
+
case dict():
|
|
25
|
+
return all(isinstance(key, str) and is_json_value(item) for key, item in value.items())
|
|
26
|
+
case _:
|
|
27
|
+
return False
|