memydev-base-sdk 0.1.0__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.
- memybase/__init__.py +88 -0
- memybase/_client.py +233 -0
- memybase/_collection.py +411 -0
- memybase/_errors.py +232 -0
- memybase/_filter.py +86 -0
- memybase/_graphql.py +49 -0
- memybase/_helpers.py +35 -0
- memybase/_mapping.py +55 -0
- memybase/_realtime.py +295 -0
- memybase/_self.py +137 -0
- memybase/_sync/__init__.py +3 -0
- memybase/_sync/_client.py +219 -0
- memybase/_sync/_collection.py +383 -0
- memybase/_sync/_graphql.py +51 -0
- memybase/_sync/_helpers.py +37 -0
- memybase/_sync/_self.py +139 -0
- memybase/_sync/_transport.py +223 -0
- memybase/_transport.py +257 -0
- memybase/_types.py +91 -0
- memybase/py.typed +0 -0
- memybase/sync/__init__.py +41 -0
- memydev_base_sdk-0.1.0.dist-info/METADATA +130 -0
- memydev_base_sdk-0.1.0.dist-info/RECORD +24 -0
- memydev_base_sdk-0.1.0.dist-info/WHEEL +4 -0
memybase/_filter.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview Fluent filter builder for MemyBase list queries.
|
|
3
|
+
@module memybase._filter
|
|
4
|
+
@description Supports the 14 operators (eq ne in nin gt gte lt lte contains startsWith istartsWith
|
|
5
|
+
endsWith exists between) matching the engine's filter contract (query-builder.ts
|
|
6
|
+
FILTER_OPERATORS). Produces the [{field,op,value}] JSON array the server expects.
|
|
7
|
+
@created 2026-07-04
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
__all__ = ["FilterBuilder", "OPERATORS", "serialize_filter"]
|
|
15
|
+
|
|
16
|
+
OPERATORS: frozenset[str] = frozenset({
|
|
17
|
+
"eq", "ne", "in", "nin", "gt", "gte", "lt", "lte",
|
|
18
|
+
"contains", "startsWith", "istartsWith", "endsWith", "exists", "between",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FilterBuilder:
|
|
23
|
+
"""Fluent builder that accumulates [{field, op, value}] conditions."""
|
|
24
|
+
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._conditions: list[dict[str, Any]] = []
|
|
27
|
+
|
|
28
|
+
def where(self, field: str, op: str, value: Any = None) -> "FilterBuilder":
|
|
29
|
+
if op not in OPERATORS:
|
|
30
|
+
raise ValueError(f"Unknown operator '{op}'; valid: {sorted(OPERATORS)}")
|
|
31
|
+
# ALWAYS serialize `value` (mirror JS filter.ts) — including a null operand. The prior
|
|
32
|
+
# `if value is not None` guard dropped it, so an is-null filter (e.g. eq('archivedAt', None))
|
|
33
|
+
# emitted [{field,op}]; the server's assertScalar(undefined) then 400s. Sending value:null lets
|
|
34
|
+
# the query-builder compile {$eq: null} and match.
|
|
35
|
+
self._conditions.append({"field": field, "op": op, "value": value})
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
def eq(self, field: str, value: Any) -> "FilterBuilder":
|
|
39
|
+
return self.where(field, "eq", value)
|
|
40
|
+
|
|
41
|
+
def ne(self, field: str, value: Any) -> "FilterBuilder":
|
|
42
|
+
return self.where(field, "ne", value)
|
|
43
|
+
|
|
44
|
+
def gt(self, field: str, value: Any) -> "FilterBuilder":
|
|
45
|
+
return self.where(field, "gt", value)
|
|
46
|
+
|
|
47
|
+
def gte(self, field: str, value: Any) -> "FilterBuilder":
|
|
48
|
+
return self.where(field, "gte", value)
|
|
49
|
+
|
|
50
|
+
def lt(self, field: str, value: Any) -> "FilterBuilder":
|
|
51
|
+
return self.where(field, "lt", value)
|
|
52
|
+
|
|
53
|
+
def lte(self, field: str, value: Any) -> "FilterBuilder":
|
|
54
|
+
return self.where(field, "lte", value)
|
|
55
|
+
|
|
56
|
+
def in_(self, field: str, values: list[Any]) -> "FilterBuilder":
|
|
57
|
+
return self.where(field, "in", values)
|
|
58
|
+
|
|
59
|
+
def nin(self, field: str, values: list[Any]) -> "FilterBuilder":
|
|
60
|
+
return self.where(field, "nin", values)
|
|
61
|
+
|
|
62
|
+
def contains(self, field: str, value: str) -> "FilterBuilder":
|
|
63
|
+
return self.where(field, "contains", value)
|
|
64
|
+
|
|
65
|
+
def starts_with(self, field: str, value: str) -> "FilterBuilder":
|
|
66
|
+
return self.where(field, "startsWith", value)
|
|
67
|
+
|
|
68
|
+
def istarts_with(self, field: str, value: str) -> "FilterBuilder":
|
|
69
|
+
"""Case-INSENSITIVE anchored prefix (server `istartsWith` — {$regex:^…, $options:'i'})."""
|
|
70
|
+
return self.where(field, "istartsWith", value)
|
|
71
|
+
|
|
72
|
+
def ends_with(self, field: str, value: str) -> "FilterBuilder":
|
|
73
|
+
return self.where(field, "endsWith", value)
|
|
74
|
+
|
|
75
|
+
def exists(self, field: str, value: bool = True) -> "FilterBuilder":
|
|
76
|
+
return self.where(field, "exists", value)
|
|
77
|
+
|
|
78
|
+
def between(self, field: str, low: Any, high: Any) -> "FilterBuilder":
|
|
79
|
+
return self.where(field, "between", [low, high])
|
|
80
|
+
|
|
81
|
+
def build(self) -> list[dict[str, Any]]:
|
|
82
|
+
return list(self._conditions)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def serialize_filter(conditions: list[dict[str, Any]]) -> str:
|
|
86
|
+
return json.dumps(conditions, separators=(",", ":"))
|
memybase/_graphql.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview GraphQL read-only client for per-project generated schemas.
|
|
3
|
+
@module memybase._graphql
|
|
4
|
+
@description Sends GraphQL queries against the per-project /api/v1 graphql endpoint and reads the
|
|
5
|
+
generated schema.sdl artifact as text. Read-only (no mutations/subscriptions). API keys
|
|
6
|
+
work. Mirrors the JS SDK's GraphQLClient.
|
|
7
|
+
@created 2026-07-04
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
from ._transport import Transport
|
|
15
|
+
|
|
16
|
+
__all__ = ["GraphQLClient"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GraphQLClient:
|
|
20
|
+
"""Async GraphQL client for a specific project (optional database)."""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
transport: Transport,
|
|
25
|
+
*,
|
|
26
|
+
project: str,
|
|
27
|
+
database: Optional[str] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._transport = transport
|
|
30
|
+
if database:
|
|
31
|
+
self._path = f"/api/v1/p/{quote(project, safe='')}/d/{quote(database, safe='')}/graphql"
|
|
32
|
+
else:
|
|
33
|
+
self._path = f"/api/v1/projects/{quote(project, safe='')}/graphql"
|
|
34
|
+
|
|
35
|
+
async def query(self, document: str, variables: Optional[dict[str, Any]] = None) -> Any:
|
|
36
|
+
"""Execute a GraphQL query and return the response body ({data, errors?})."""
|
|
37
|
+
body: dict[str, Any] = {"query": document}
|
|
38
|
+
if variables:
|
|
39
|
+
body["variables"] = variables
|
|
40
|
+
return await self._transport.request("POST", self._path, json_body=body)
|
|
41
|
+
|
|
42
|
+
async def schema_sdl(self) -> str:
|
|
43
|
+
"""GET {graphql_path}/schema.sdl as application/graphql text."""
|
|
44
|
+
return await self._transport.request(
|
|
45
|
+
"GET",
|
|
46
|
+
f"{self._path}/schema.sdl",
|
|
47
|
+
headers={"Accept": "application/graphql"},
|
|
48
|
+
text=True,
|
|
49
|
+
)
|
memybase/_helpers.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview Conflict retry helper for expected-version race conditions.
|
|
3
|
+
@module memybase._helpers
|
|
4
|
+
@description MemyBase writes accept expected_version / If-Match preconditions; this helper retries on
|
|
5
|
+
ConflictError with linear backoff, mirroring the JS SDK's withConflictRetry().
|
|
6
|
+
@created 2026-07-04
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Any, Awaitable, Callable, TypeVar
|
|
12
|
+
|
|
13
|
+
from memybase._errors import ConflictError # absolute: shared module, no _sync twin (see gen_sync.py)
|
|
14
|
+
|
|
15
|
+
__all__ = ["with_conflict_retry"]
|
|
16
|
+
|
|
17
|
+
T = TypeVar("T")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def with_conflict_retry(
|
|
21
|
+
fn: Callable[[], Awaitable[T]],
|
|
22
|
+
*,
|
|
23
|
+
retries: int = 3,
|
|
24
|
+
delay: float = 0.2,
|
|
25
|
+
) -> T:
|
|
26
|
+
"""Retry fn() on ConflictError up to `retries` times with linear backoff."""
|
|
27
|
+
last_error: ConflictError | None = None
|
|
28
|
+
for attempt in range(retries + 1):
|
|
29
|
+
try:
|
|
30
|
+
return await fn()
|
|
31
|
+
except ConflictError as e:
|
|
32
|
+
last_error = e
|
|
33
|
+
if attempt < retries:
|
|
34
|
+
await asyncio.sleep(delay * (attempt + 1))
|
|
35
|
+
raise last_error # type: ignore[misc]
|
memybase/_mapping.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview Reserved-field guard and payload utilities.
|
|
3
|
+
@module memybase._mapping
|
|
4
|
+
@description Guards write payloads against the 15 non-writable engine-reserved system fields while
|
|
5
|
+
allowing the schema-reserved memyTtl exception. Strips
|
|
6
|
+
undefined/None keys from outbound payloads (null-omit rule). Mirrors the JS SDK's
|
|
7
|
+
mapping.ts and MemySwarm's mapping.py.
|
|
8
|
+
@created 2026-07-04
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Mapping
|
|
13
|
+
|
|
14
|
+
from ._errors import ReservedFieldError
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"CUSTOMER_WRITABLE_RESERVED_FIELDS",
|
|
18
|
+
"RESERVED_FIELDS",
|
|
19
|
+
"SCHEMA_RESERVED_FIELDS",
|
|
20
|
+
"guard_reserved_fields",
|
|
21
|
+
"omit_none",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
RESERVED_FIELDS: frozenset[str] = frozenset({
|
|
25
|
+
"_id",
|
|
26
|
+
"tenantId",
|
|
27
|
+
"projectId",
|
|
28
|
+
"entityId",
|
|
29
|
+
"schemaVersionId",
|
|
30
|
+
"createdAt",
|
|
31
|
+
"createdBy",
|
|
32
|
+
"updatedAt",
|
|
33
|
+
"updatedBy",
|
|
34
|
+
"deletedAt",
|
|
35
|
+
"deletedBy",
|
|
36
|
+
"deleteReason",
|
|
37
|
+
"version",
|
|
38
|
+
"_metadata",
|
|
39
|
+
"_system",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
SCHEMA_RESERVED_FIELDS: frozenset[str] = RESERVED_FIELDS | {"memyTtl"}
|
|
43
|
+
CUSTOMER_WRITABLE_RESERVED_FIELDS: frozenset[str] = frozenset({"memyTtl"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def guard_reserved_fields(body: Mapping[str, Any]) -> None:
|
|
47
|
+
"""Raise ReservedFieldError if body contains any reserved key."""
|
|
48
|
+
violations = RESERVED_FIELDS.intersection(body.keys())
|
|
49
|
+
if violations:
|
|
50
|
+
raise ReservedFieldError(sorted(violations))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def omit_none(body: Mapping[str, Any]) -> dict[str, Any]:
|
|
54
|
+
"""Return a copy of body with None-valued keys removed (null-omit rule)."""
|
|
55
|
+
return {k: v for k, v in body.items() if v is not None}
|
memybase/_realtime.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview SSE realtime subscription — ticket-based streaming with auto-reconnect.
|
|
3
|
+
@module memybase._realtime
|
|
4
|
+
@description Python port of the JS SDK's realtime.ts. Two-step flow: POST .../streams/tickets →
|
|
5
|
+
GET .../stream?ticket=. Parses SSE frames over an httpx streaming response, auto-
|
|
6
|
+
reconnects with bounded exponential backoff, re-mints the ticket on each attempt, tracks
|
|
7
|
+
Last-Event-ID for resume, and invokes on_resync when the server requests a re-snapshot.
|
|
8
|
+
ASYNC-ONLY: SSE is inherently async and the sync client (memybase.sync) targets scripts /
|
|
9
|
+
provisioning tooling with no event loop — realtime is deliberately not exposed there
|
|
10
|
+
(see sdk/py/tests/test_sync_parity.py's documented exclusion), mirroring the JS SDK,
|
|
11
|
+
which likewise ships realtime only on its single (async) client.
|
|
12
|
+
@dependencies ._transport (open_sse), httpx (transitively)
|
|
13
|
+
@relatedFiles ./_collection.py (Collection.subscribe), ./_client.py (MemyBase.stream),
|
|
14
|
+
../../../js/src/realtime.ts (the reference implementation this mirrors 1:1)
|
|
15
|
+
@created 2026-07-05
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import contextlib
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any, Callable, Optional
|
|
24
|
+
from urllib.parse import quote, urlsplit
|
|
25
|
+
|
|
26
|
+
from ._transport import Transport
|
|
27
|
+
|
|
28
|
+
__all__ = ["RealtimeClient", "StreamEvent", "SubscribeOptions", "Subscription"]
|
|
29
|
+
|
|
30
|
+
# Backoff parity with realtime.ts: delay = min(1000 * 2^(n-1), 30_000) ms → seconds here.
|
|
31
|
+
_RECONNECT_BASE = 1.0
|
|
32
|
+
_RECONNECT_MAX = 30.0
|
|
33
|
+
# Floor applied to EVERY reconnect delay — including a CLEAN stream end (EOF, no exception). A stream that
|
|
34
|
+
# opens then immediately EOFs (LB idle-timeout, draining backend, crash-loop) would otherwise re-mint a
|
|
35
|
+
# ticket + reconnect with zero delay, an RTT-bound hot loop hammering /streams/tickets. The floor bounds it.
|
|
36
|
+
_RECONNECT_MIN = 0.5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class StreamEvent:
|
|
41
|
+
"""A realtime event — the faithful Python mirror of the JS StreamEvent discriminated union:
|
|
42
|
+
|
|
43
|
+
- event='ready' data = {mode, entity, heartbeatMs}
|
|
44
|
+
- event='change' data = {type, entity, id, op?, doc?} (id = SSE id or None)
|
|
45
|
+
- event='heartbeat' data = <epoch-ms int>
|
|
46
|
+
- event='resync' data = {reason}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
event: str
|
|
50
|
+
data: Any
|
|
51
|
+
id: Optional[str] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class SubscribeOptions:
|
|
56
|
+
"""Subscription options — 1:1 with the JS SubscribeOptions {entity, lastEventId, onResync, signal}.
|
|
57
|
+
|
|
58
|
+
signal maps the JS AbortSignal to the idiomatic Python cancellation token (asyncio.Event): set it
|
|
59
|
+
to abort the subscription from outside. The primary stop path is ``await subscription.close()``.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
entity: Optional[str] = None
|
|
63
|
+
last_event_id: Optional[str] = None
|
|
64
|
+
on_resync: Optional[Callable[[str], None]] = None
|
|
65
|
+
signal: Optional[asyncio.Event] = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
EventCallback = Callable[["StreamEvent"], None]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Subscription:
|
|
72
|
+
"""Handle to a live subscription.
|
|
73
|
+
|
|
74
|
+
``await subscription.close()`` cancels AND drains the background task (a deliberate divergence
|
|
75
|
+
from the JS ``close(): void`` so resources tear down cleanly). ``closed`` reflects state; ``wait()``
|
|
76
|
+
awaits the background task until it stops. Also an async context manager.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
self._closed = False
|
|
81
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
82
|
+
self._watch_task: Optional[asyncio.Task[None]] = None
|
|
83
|
+
self._last_event_id: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def closed(self) -> bool:
|
|
87
|
+
return self._closed
|
|
88
|
+
|
|
89
|
+
async def close(self) -> None:
|
|
90
|
+
self._closed = True
|
|
91
|
+
for task in (self._task, self._watch_task):
|
|
92
|
+
if task is not None:
|
|
93
|
+
task.cancel()
|
|
94
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
95
|
+
await task
|
|
96
|
+
|
|
97
|
+
async def wait(self) -> None:
|
|
98
|
+
"""Await the background reconnect task until the subscription stops (closed / terminal)."""
|
|
99
|
+
if self._task is not None:
|
|
100
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
101
|
+
await self._task
|
|
102
|
+
|
|
103
|
+
async def __aenter__(self) -> "Subscription":
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
107
|
+
await self.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _resync_reason(data: Any) -> str:
|
|
111
|
+
"""Extract a resync reason robustly, mirroring realtime.ts `parsed.reason ?? 'unknown'`. A non-object
|
|
112
|
+
payload (a bare scalar) or a null/missing reason both collapse to 'unknown' — never an AttributeError
|
|
113
|
+
that would drop the whole event (the prior `data.get(...)` raised on a scalar payload)."""
|
|
114
|
+
reason = data.get("reason") if isinstance(data, dict) else None
|
|
115
|
+
return reason if isinstance(reason, str) else "unknown"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _stream_path(stream: str) -> str:
|
|
119
|
+
"""Mirror realtime.ts: an absolute '/...' path is used verbatim; a full URL → path+query only."""
|
|
120
|
+
if stream.startswith("/"):
|
|
121
|
+
return stream
|
|
122
|
+
parts = urlsplit(stream)
|
|
123
|
+
return parts.path + (f"?{parts.query}" if parts.query else "")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class RealtimeClient:
|
|
127
|
+
"""Async SSE realtime client. Obtain subscriptions via ``MemyBase.stream`` or
|
|
128
|
+
``Collection.subscribe``; direct use is also supported."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, transport: Transport) -> None:
|
|
131
|
+
self._transport = transport
|
|
132
|
+
|
|
133
|
+
async def subscribe(
|
|
134
|
+
self,
|
|
135
|
+
project: str,
|
|
136
|
+
database: Optional[str],
|
|
137
|
+
on_event: EventCallback,
|
|
138
|
+
opts: Optional[SubscribeOptions] = None,
|
|
139
|
+
) -> Subscription:
|
|
140
|
+
"""Start a subscription; returns immediately with a live Subscription (the reconnect loop runs
|
|
141
|
+
in a background task, mirroring realtime.ts firing ``connect()`` without awaiting)."""
|
|
142
|
+
opts = opts or SubscribeOptions()
|
|
143
|
+
|
|
144
|
+
if database:
|
|
145
|
+
ticket_path = (
|
|
146
|
+
f"/api/v1/p/{quote(project, safe='')}/d/{quote(database, safe='')}/streams/tickets"
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
ticket_path = f"/api/v1/projects/{quote(project, safe='')}/streams/tickets"
|
|
150
|
+
|
|
151
|
+
sub = Subscription()
|
|
152
|
+
sub._last_event_id = opts.last_event_id
|
|
153
|
+
sub._task = asyncio.create_task(self._run(ticket_path, on_event, opts, sub))
|
|
154
|
+
|
|
155
|
+
if opts.signal is not None:
|
|
156
|
+
async def _watch() -> None:
|
|
157
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
158
|
+
await opts.signal.wait() # type: ignore[union-attr]
|
|
159
|
+
await sub.close()
|
|
160
|
+
|
|
161
|
+
sub._watch_task = asyncio.create_task(_watch())
|
|
162
|
+
|
|
163
|
+
return sub
|
|
164
|
+
|
|
165
|
+
async def _run(
|
|
166
|
+
self,
|
|
167
|
+
ticket_path: str,
|
|
168
|
+
on_event: EventCallback,
|
|
169
|
+
opts: SubscribeOptions,
|
|
170
|
+
sub: Subscription,
|
|
171
|
+
) -> None:
|
|
172
|
+
# Reconnect loop mirroring realtime.ts connect(): re-mint the ticket every iteration, reset the
|
|
173
|
+
# backoff after the stream opens, preserve Last-Event-ID across reconnects. Reconnects on ANY
|
|
174
|
+
# error (including 401/403/404) exactly like the JS SDK — see the module note.
|
|
175
|
+
reconnect_attempt = 0
|
|
176
|
+
while not sub._closed:
|
|
177
|
+
try:
|
|
178
|
+
# Yield each iteration so a tight clean-reconnect loop (a server that closes the stream
|
|
179
|
+
# immediately) can never starve concurrent tasks, and so cancellation from close() is
|
|
180
|
+
# always observed promptly. A no-op on the healthy path — a live SSE stream blocks in
|
|
181
|
+
# aiter_lines, so this only fires between (re)connections.
|
|
182
|
+
await asyncio.sleep(0)
|
|
183
|
+
ticket_body: dict[str, Any] = {}
|
|
184
|
+
if opts.entity:
|
|
185
|
+
ticket_body["entity"] = opts.entity
|
|
186
|
+
ticket = await self._transport.request(
|
|
187
|
+
"POST", ticket_path, json_body=ticket_body or None
|
|
188
|
+
)
|
|
189
|
+
stream_path = _stream_path(ticket["stream"])
|
|
190
|
+
async with self._transport.open_sse(
|
|
191
|
+
stream_path, last_event_id=sub._last_event_id
|
|
192
|
+
) as resp:
|
|
193
|
+
reconnect_attempt = 0
|
|
194
|
+
await self._consume(resp, on_event, opts, sub)
|
|
195
|
+
# Reached here ⇒ the stream ended CLEANLY (EOF, no exception). This is still a reconnect,
|
|
196
|
+
# so it gets a FLOOR backoff (rank 34) — otherwise a fast-closing stream re-mints with no
|
|
197
|
+
# delay. Route it through the same throttle as the error path.
|
|
198
|
+
if sub._closed:
|
|
199
|
+
return
|
|
200
|
+
reconnect_attempt += 1
|
|
201
|
+
await self._backoff_sleep(reconnect_attempt)
|
|
202
|
+
except asyncio.CancelledError:
|
|
203
|
+
return
|
|
204
|
+
except Exception:
|
|
205
|
+
if sub._closed:
|
|
206
|
+
return
|
|
207
|
+
reconnect_attempt += 1
|
|
208
|
+
await self._backoff_sleep(reconnect_attempt)
|
|
209
|
+
|
|
210
|
+
async def _backoff_sleep(self, attempt: int) -> None:
|
|
211
|
+
"""Sleep the reconnect backoff for ``attempt``, never below ``_RECONNECT_MIN`` (so even a clean
|
|
212
|
+
fast-close is throttled). Returns quietly on cancellation from close()."""
|
|
213
|
+
delay = min(_RECONNECT_BASE * (2 ** (attempt - 1)), _RECONNECT_MAX)
|
|
214
|
+
delay = max(delay, _RECONNECT_MIN)
|
|
215
|
+
try:
|
|
216
|
+
await asyncio.sleep(delay)
|
|
217
|
+
except asyncio.CancelledError:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
async def _consume(
|
|
221
|
+
self,
|
|
222
|
+
resp: Any,
|
|
223
|
+
on_event: EventCallback,
|
|
224
|
+
opts: SubscribeOptions,
|
|
225
|
+
sub: Subscription,
|
|
226
|
+
) -> None:
|
|
227
|
+
# SSE frame parser mirroring consumeStream/dispatchEvent EXACTLY. httpx aiter_lines() yields
|
|
228
|
+
# each line (newline stripped) and an empty string '' for a blank-line frame separator.
|
|
229
|
+
current_event = ""
|
|
230
|
+
current_data = ""
|
|
231
|
+
current_id = ""
|
|
232
|
+
async for line in resp.aiter_lines():
|
|
233
|
+
if sub._closed:
|
|
234
|
+
return
|
|
235
|
+
if line == "":
|
|
236
|
+
if current_data:
|
|
237
|
+
if current_id:
|
|
238
|
+
sub._last_event_id = current_id
|
|
239
|
+
self._dispatch(current_event, current_data, current_id, on_event, opts)
|
|
240
|
+
current_event = ""
|
|
241
|
+
current_data = ""
|
|
242
|
+
current_id = ""
|
|
243
|
+
continue
|
|
244
|
+
if line.startswith(":"): # comment / keep-alive
|
|
245
|
+
continue
|
|
246
|
+
colon = line.find(":")
|
|
247
|
+
if colon == -1:
|
|
248
|
+
field_name, value = line, ""
|
|
249
|
+
else:
|
|
250
|
+
field_name = line[:colon]
|
|
251
|
+
value = line[colon + 1 :]
|
|
252
|
+
if value.startswith(" "): # strip exactly one leading space
|
|
253
|
+
value = value[1:]
|
|
254
|
+
if field_name == "event":
|
|
255
|
+
current_event = value
|
|
256
|
+
elif field_name == "data":
|
|
257
|
+
current_data = f"{current_data}\n{value}" if current_data else value
|
|
258
|
+
elif field_name == "id":
|
|
259
|
+
current_id = value
|
|
260
|
+
|
|
261
|
+
def _dispatch(
|
|
262
|
+
self,
|
|
263
|
+
event: str,
|
|
264
|
+
data: str,
|
|
265
|
+
id_: str,
|
|
266
|
+
on_event: EventCallback,
|
|
267
|
+
opts: SubscribeOptions,
|
|
268
|
+
) -> None:
|
|
269
|
+
# 1. PARSE — a malformed SSE payload skips the frame (mirrors realtime.ts). Kept narrow so a bad
|
|
270
|
+
# payload can never be confused with a consumer-callback error handled separately below.
|
|
271
|
+
try:
|
|
272
|
+
if event == "ready":
|
|
273
|
+
evt = StreamEvent("ready", json.loads(data))
|
|
274
|
+
elif event == "change":
|
|
275
|
+
evt = StreamEvent("change", json.loads(data), id=id_ or None)
|
|
276
|
+
elif event == "heartbeat":
|
|
277
|
+
evt = StreamEvent("heartbeat", int(data))
|
|
278
|
+
elif event == "resync":
|
|
279
|
+
evt = StreamEvent("resync", json.loads(data))
|
|
280
|
+
else:
|
|
281
|
+
return
|
|
282
|
+
except (ValueError, TypeError):
|
|
283
|
+
return # malformed SSE data — skip the frame
|
|
284
|
+
|
|
285
|
+
# 2. DISPATCH — a consumer callback (on_event / on_resync) raising must NEVER propagate into _run's
|
|
286
|
+
# reconnect `except Exception`: otherwise a buggy callback (e.g. a KeyError on a missing field)
|
|
287
|
+
# turns into an unbounded ticket-mint + reconnect storm against /streams/tickets. Swallow here,
|
|
288
|
+
# exactly like realtime.ts's dispatchEvent catch — callback failures are isolated from transport
|
|
289
|
+
# failures, and the stream keeps flowing.
|
|
290
|
+
try:
|
|
291
|
+
if evt.event == "resync" and opts.on_resync:
|
|
292
|
+
opts.on_resync(_resync_reason(evt.data))
|
|
293
|
+
on_event(evt)
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
memybase/_self.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview Customer self-service operations on /api/self/*.
|
|
3
|
+
@module memybase._self
|
|
4
|
+
@description Mirrors the /api/self router 1:1: profile, openapi, projects CRUD + archive/restore,
|
|
5
|
+
databases CRUD, schema versions/activate, api-keys,
|
|
6
|
+
settings, plan, usage. All methods require tenant-level auth.
|
|
7
|
+
@created 2026-07-04
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import quote
|
|
13
|
+
|
|
14
|
+
# Absolute import of the shared/pure module (same pattern as _collection.py / _transport.py): unasync
|
|
15
|
+
# generates memybase/_sync/_self.py, whose relative `from ._types` would resolve to a nonexistent
|
|
16
|
+
# memybase/_sync/_types.py — the absolute path makes both the async module and its sync twin bind the
|
|
17
|
+
# SAME single _types module.
|
|
18
|
+
from memybase._types import SchemaActivationJobSummary
|
|
19
|
+
|
|
20
|
+
from ._transport import Transport
|
|
21
|
+
|
|
22
|
+
__all__ = ["SelfService"]
|
|
23
|
+
|
|
24
|
+
_e = lambda s: quote(str(s), safe="") # noqa: E731
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SelfService:
|
|
28
|
+
"""Async handle for authenticated /api/self/* customer endpoints."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, transport: Transport) -> None:
|
|
31
|
+
self._t = transport
|
|
32
|
+
|
|
33
|
+
# ── Profile ──────────────────────────────────────────────────────────────
|
|
34
|
+
async def profile(self) -> Any:
|
|
35
|
+
return await self._t.request("GET", "/api/self/profile")
|
|
36
|
+
|
|
37
|
+
async def openapi(self) -> Any:
|
|
38
|
+
return await self._t.request("GET", "/api/self/openapi.json")
|
|
39
|
+
|
|
40
|
+
# ── Projects ─────────────────────────────────────────────────────────────
|
|
41
|
+
async def list_projects(self) -> Any:
|
|
42
|
+
return await self._t.request("GET", "/api/self/projects")
|
|
43
|
+
|
|
44
|
+
async def create_project(self, body: dict[str, Any]) -> Any:
|
|
45
|
+
return await self._t.request("POST", "/api/self/projects", json_body=body)
|
|
46
|
+
|
|
47
|
+
async def get_project(self, ref: str) -> Any:
|
|
48
|
+
return await self._t.request("GET", f"/api/self/projects/{_e(ref)}")
|
|
49
|
+
|
|
50
|
+
async def update_project(self, ref: str, body: dict[str, Any]) -> Any:
|
|
51
|
+
return await self._t.request("PATCH", f"/api/self/projects/{_e(ref)}", json_body=body)
|
|
52
|
+
|
|
53
|
+
async def archive_project(self, ref: str) -> None:
|
|
54
|
+
await self._t.request("POST", f"/api/self/projects/{_e(ref)}/archive")
|
|
55
|
+
|
|
56
|
+
async def restore_project(self, ref: str) -> None:
|
|
57
|
+
await self._t.request("POST", f"/api/self/projects/{_e(ref)}/restore")
|
|
58
|
+
|
|
59
|
+
async def delete_project(self, ref: str) -> None:
|
|
60
|
+
await self._t.request("DELETE", f"/api/self/projects/{_e(ref)}")
|
|
61
|
+
|
|
62
|
+
# ── Databases ────────────────────────────────────────────────────────────
|
|
63
|
+
async def list_databases(self, project_ref: str) -> Any:
|
|
64
|
+
return await self._t.request("GET", f"/api/self/projects/{_e(project_ref)}/databases")
|
|
65
|
+
|
|
66
|
+
async def create_database(self, project_ref: str, body: dict[str, Any]) -> Any:
|
|
67
|
+
return await self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases", json_body=body)
|
|
68
|
+
|
|
69
|
+
async def delete_database(self, project_ref: str, db: str) -> None:
|
|
70
|
+
await self._t.request("DELETE", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}")
|
|
71
|
+
|
|
72
|
+
async def archive_database(self, project_ref: str, db: str) -> None:
|
|
73
|
+
await self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}/archive")
|
|
74
|
+
|
|
75
|
+
async def restore_database(self, project_ref: str, db: str) -> None:
|
|
76
|
+
await self._t.request("POST", f"/api/self/projects/{_e(project_ref)}/databases/{_e(db)}/restore")
|
|
77
|
+
|
|
78
|
+
# ── Schema ───────────────────────────────────────────────────────────────
|
|
79
|
+
async def list_schema_versions(self, project_ref: str, db: str | None = None) -> Any:
|
|
80
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
81
|
+
path = f"{base}/databases/{_e(db)}/schema/versions" if db else f"{base}/schema/versions"
|
|
82
|
+
return await self._t.request("GET", path)
|
|
83
|
+
|
|
84
|
+
async def activate_schema(self, project_ref: str, draft: dict[str, Any], db: str | None = None) -> Any:
|
|
85
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
86
|
+
path = f"{base}/databases/{_e(db)}/schema/activate" if db else f"{base}/schema/activate"
|
|
87
|
+
return await self._t.request("POST", path, json_body=draft)
|
|
88
|
+
|
|
89
|
+
async def activate_schema_spec(self, project_ref: str, spec: dict[str, Any], db: str | None = None) -> Any:
|
|
90
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
91
|
+
path = f"{base}/databases/{_e(db)}/schema/design" if db else f"{base}/schema/design"
|
|
92
|
+
return await self._t.request("POST", path, json_body=spec)
|
|
93
|
+
|
|
94
|
+
async def get_schema_activation(
|
|
95
|
+
self, project_ref: str, job_id: str, db: str | None = None
|
|
96
|
+
) -> SchemaActivationJobSummary:
|
|
97
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
98
|
+
path = f"{base}/databases/{_e(db)}/schema/activations/{_e(job_id)}" if db else f"{base}/schema/activations/{_e(job_id)}"
|
|
99
|
+
return await self._t.request("GET", path)
|
|
100
|
+
|
|
101
|
+
async def rollback_schema_activation(
|
|
102
|
+
self, project_ref: str, job_id: str, db: str | None = None
|
|
103
|
+
) -> SchemaActivationJobSummary:
|
|
104
|
+
base = f"/api/self/projects/{_e(project_ref)}"
|
|
105
|
+
path = f"{base}/databases/{_e(db)}/schema/activations/{_e(job_id)}/rollback" if db else f"{base}/schema/activations/{_e(job_id)}/rollback"
|
|
106
|
+
return await self._t.request("POST", path)
|
|
107
|
+
|
|
108
|
+
# ── Settings ─────────────────────────────────────────────────────────────
|
|
109
|
+
async def get_settings(self, scope_type: str | None = None, scope_id: str | None = None) -> Any:
|
|
110
|
+
if scope_type and scope_id:
|
|
111
|
+
return await self._t.request("GET", f"/api/self/settings/{_e(scope_type)}/{_e(scope_id)}")
|
|
112
|
+
return await self._t.request("GET", "/api/self/settings")
|
|
113
|
+
|
|
114
|
+
async def update_settings(self, values: dict[str, Any], scope_type: str | None = None, scope_id: str | None = None) -> Any:
|
|
115
|
+
if scope_type and scope_id:
|
|
116
|
+
return await self._t.request("PUT", f"/api/self/settings/{_e(scope_type)}/{_e(scope_id)}", json_body=values)
|
|
117
|
+
return await self._t.request("PUT", "/api/self/settings", json_body=values)
|
|
118
|
+
|
|
119
|
+
# ── API Keys ─────────────────────────────────────────────────────────────
|
|
120
|
+
async def list_api_keys(self) -> Any:
|
|
121
|
+
return await self._t.request("GET", "/api/self/api-keys")
|
|
122
|
+
|
|
123
|
+
async def create_api_key(self, body: dict[str, Any]) -> Any:
|
|
124
|
+
return await self._t.request("POST", "/api/self/api-keys", json_body=body)
|
|
125
|
+
|
|
126
|
+
async def revoke_api_key(self, key_id: str) -> None:
|
|
127
|
+
await self._t.request("DELETE", f"/api/self/api-keys/{_e(key_id)}")
|
|
128
|
+
|
|
129
|
+
async def purge_api_key(self, key_id: str) -> None:
|
|
130
|
+
await self._t.request("DELETE", f"/api/self/api-keys/{_e(key_id)}/purge")
|
|
131
|
+
|
|
132
|
+
# ── Plan & Usage ─────────────────────────────────────────────────────────
|
|
133
|
+
async def get_plan(self) -> Any:
|
|
134
|
+
return await self._t.request("GET", "/api/self/plan")
|
|
135
|
+
|
|
136
|
+
async def get_usage(self) -> Any:
|
|
137
|
+
return await self._t.request("GET", "/api/self/usage")
|