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,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__
@@ -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