morphdb.js
The frontend SDK, served by the backend it talks to — one script tag, zero config, zero dependencies.
Every MorphDB-backed site today ships a hand-pasted db() helper and hand-built query strings. That helper is an SDK — it's just distributed by copy-paste. This spec makes it official: a single ~400-line file the server itself serves at GET /sdk.js, giving every vibe-coded frontend the same Firebase-grade ergonomics without a build step, an npm install, or a version matrix.
The gap today
MorphDB's contract has two planes: the agent reshapes the schema through the CLI, and the frontend reads and writes objects over plain HTTP. The second plane has no client. The skill file tells every agent to paste a ~45-line fetch wrapper into every site it builds, and then to hand-assemble query strings against it.
The demand signal is already in this repo, three times over:
SKILL.md §2— the canonical "drop-in FE client" snippet, re-emitted into every generated site.examples/linkedin,examples/figma— each carries its own slightly different copy.specs/comments.js— the spec-comments widget on this very page hand-rolls a fourth: anapi()wrapper plus anensureBackend()bootstrap that registers the app and merges the schema from the browser.
Copy-paste distribution has real costs, and they land on the primary user — a coding agent mid-build:
- Tokens. ~45 lines of boilerplate re-generated per site, plus hand-built query strings (
"/objects/task?done=false&assignee=" + guid + "&sort=priority") at every call site. - A whole bug class. Un-encoded values,
__inlists joined wrong, booleans stringified wrong, the{objects}envelope forgotten. Each is a debug round-trip. - Drift. A fix to the helper ships to zero existing sites. Each copy ages alone.
- No liveness. Nothing helps a site show fresh data; every app that wants it hand-rolls polling (the Figma clone does exactly this).
Firebase settled this shape a decade ago: a generic backend plus an official client SDK, so application code speaks the SDK and the wire format is an implementation detail. MorphDB is the same shape backend — it's missing the same shape client. The twist worth designing for: our SDK's main caller is not a human reading docs, it's an agent generating code. Optimize for fewest tokens to a correct call, and for error messages that teach the fix.
Goals & non-goals
2.1Goals
- G1 — One-line adoption. A single
<script>tag served by the backend itself. On localhost, zero configuration: the SDK infers the host from the tag that loaded it. - G2 — Full object surface, 1:1. Everything the seven object endpoints do — filters, relation filters,
include, sort, pagination — and nothing they don't. No invented semantics. - G3 — Agent-first ergonomics. One options object per call, whose keys are exactly the server's query-string keys. Typed errors that carry the server's teaching messages through verbatim.
- G4 — Live-ish UI.
watch()— a Firestore-onSnapshot-shaped subscription implemented by polling, with a contract that upgrades to server push later without changing app code. - G5 — Portable-app parity.
morphdb.init(schemaDoc)— the browser-side twin of the CLI'smorphdb init: register-if-missing, merge-additively, never destructive. - G6 — Zero toolchain. One hand-written ES2020 file inside the Python package. No bundler, no TypeScript compile, no npm publish required. JSDoc for editor/agent IntelliSense.
2.2Non-goals
- N1 — No offline cache, no optimistic mutations. That's the ~100 KB half of Firebase, built for flaky mobile networks. MorphDB is a localhost-scale dev tool; a request either lands or throws.
- N2 — No schema editing surface. The two-plane split is the product: schema belongs to the agent's CLI. The single, deliberate exception is §08's
init— additive bootstrap parity, not schema authoring. - N3 — No auth. MorphRules owns identity and per-user rules. This SDK reserves the seam (§10, §13-D9) and otherwise stays out.
- N4 — Not an ORM. No model classes, no proxies, no change tracking. Objects are the plain JSON the server returns,
_guidand all. - N5 — No per-app codegen. No generated types, no schema-derived client. One generic client for every app, like the endpoints it wraps.
- N6 — No npm package yet. Deferred until someone actually asks for a bundler workflow (§14). The served file is the distribution.
Prior art — what we take, what we leave
| SDK | Take | Leave |
|---|---|---|
| Firebase / Firestore | The core bet: app code speaks an official client, never raw wire. The onSnapshot subscription shape — query in, callback of current-state out, unsubscribe function back — is the right liveness API and survives a transport swap. |
The size (offline cache, sync engine), the config-object ceremony (apiKey, projectId, …), the v9 modular import maze. Firestore needs them; a single-process dev backend doesn't. |
| supabase-js | Thin-veneer honesty: the client is openly a query-string builder over PostgREST, and returns are the server's shapes. That's our 1:1 rule. | The chainable builder (.select().eq().order()). It reads nicely but costs more tokens than one options object and creates order-of-calls trivia an agent can get wrong. |
| PocketBase js-sdk | The closest cousin — single-binary backend with a first-party one-file SDK, versioned in lockstep with the server. Confirms the "SDK travels with the backend" instinct. | Class hierarchy, auth stores, realtime manager — that's PocketBase's production ambition. Ours stays a dev-tool veneer until MorphRules lands. |
| In-tree helpers ×3 (SKILL.md · examples · comments.js) | They are the requirements doc: header injection, JSON errors, 204→null, schema bootstrap, polling refresh — every feature below exists because a copy already hand-rolled it. | The copy-paste distribution itself. |
Design overview
One file, morphdb/sdk.js, shipped inside the Python package and served by every MorphDB — localhost, hosted Lambda, any storage backend — at GET /sdk.js. It defines a single global, morphdb. Everything else hangs off it:
hello, whole surface<!-- one tag, served by your own backend -->
<script src="http://127.0.0.1:8787/sdk.js"></script>
<script type="module">
const db = morphdb("my-cool-site"); // host inferred from the script tag
const tasks = db.type("task");
const ann = await db.type("user").create({ name: "Ann" });
const t = await tasks.create({ title: "buy milk", assignee: ann._guid });
const { objects, total } = await tasks.list({
where: { done: false, assignee: ann._guid },
sort: "priority", order: "desc", limit: 20,
include: "assignee",
});
await tasks.update(t._guid, { done: true }); // PATCH
await tasks.delete(t._guid);
</script>
The design rule that keeps this small and honest: every SDK method is exactly one HTTP request to an endpoint that already exists (the two composites, watch and init, are loops over those same requests — §07, §08). The full mapping:
| SDK call | Wire |
|---|---|
db.type("task").create(body) | POST /objects/task |
.get(guid, {include}) | GET /objects/task/{guid}?include=… |
.list({where, sort, order, limit, offset, include}) | GET /objects/task?… |
.first(q) | list with limit:1 → objects[0] ?? null |
.update(guid, patch) | PATCH /objects/task/{guid} |
.put(guid, body) | PUT /objects/task/{guid} |
.delete(guid) | DELETE /objects/task/{guid} |
db.object(guid, {include}) | GET /object/{guid}?include=… |
.watch(q, cb, opts) | GET /objects/task?… on an interval (§07) |
morphdb.init(schemaDoc) | POST /app (409-tolerant) + PUT /schema/{type} merge, per type (§08) |
db.raw(method, path, body) | any route — the escape hatch |
No wrapper types on the way out: what the server returns is what the caller gets — {objects, total, limit, offset} envelopes, plain objects with _guid / _type / _created_at / _updated_at, relations as guids unless include hydrated them. The existing docs stay true sentence-for-sentence; the SDK just types the URL for you.
The SDK is a veneer, not a layer. It never caches, never rewrites shapes, never retries writes. Its value is mechanical: correct URLs, correct encoding, correct headers, typed errors, and two carefully-scoped composites (watch, init). Anything smarter waits for a server primitive to exist first.
The client & connection
constructorconst db = morphdb(appKey, {
host, // optional — see resolution order below
headers, // optional extra headers on every request (escape hatch)
fetch, // optional fetch impl (tests, node) — defaults to globalThis.fetch
});
appKey is required and explicit — it's the app's identity and belongs in the app's source, not in ambient globals. host resolves through a chain, first hit wins:
opts.host— full URL or barehost:port(same normalization the current helper does).window.MORPHDB_HOST— back-compat with the convention every existing site and the hosted deploys already use.- The origin of the script tag that loaded the SDK (
document.currentScript.src, captured at load time). This is the trick that makes localhost zero-config: the file came from the backend, so the backend's address is already known. It's also self-correcting for hosted instances — load the SDK from the Lambda URL and you're pointed at the Lambda. http://127.0.0.1:8787— the default the whole tool already assumes.
Because app code needs top-level await, samples and SKILL.md put it in a <script type="module"> (or any async function) — the SDK file itself stays a classic script so the global exists everywhere, including file:// pages (§13-D1).
Three small things ride on the client, all one-liners to implement:
db.raw(method, path, body)— the currentdb()helper, kept as the eject valve. Any endpoint the SDK hasn't wrapped (a future/auth, a debugging poke at/schema) stays reachable without forking the file. Pressure to add wrappers gets pointed here first.morphdb.version— the server's version string, stamped into the file at serve time (§10).db.type(name)— returns the per-type handle (§06). Handles are stateless and cheap;db.type("task")twice is fine.
Objects, queries, relations
The per-type handle carries the seven verbs from §04's table. The only API with any surface area is list, and its design principle is: the options object's keys are the server's query-string keys, verbatim. where keys are field names with the existing double-underscore operators — done, priority__gt, title__contains, assignee__exists. Nothing to translate, so the endpoint docs and the SDK docs are the same docs.
what the sdk actually saves you// before — hand-built, three latent bugs (encoding, joining, envelope)
const r = await db("GET", "/objects/task?done=false&assignee=" + g +
"&priority__in=" + prios.join() + "&sort=priority&order=desc");
// after — every value encoded, arrays comma-joined, Dates → ISO-8601
const { objects } = await tasks.list({
where: { done: false, assignee: g, priority__in: prios },
sort: "priority", order: "desc",
});
Value handling is the whole job, so it's specified exactly:
JS value in where / body | On the wire |
|---|---|
string · number · boolean | stringified, URL-encoded |
Array (for __in) | comma-joined, each element encoded |
Date | .toISOString() — matches the server's ISO-8601 datetime validation |
null · undefined in where | rejected client-side with a pointer to __exists — the classic silent-filter bug, made loud |
Relations get no special API, exactly like the wire: write a guid or a list of guids as a field value, filter through where (assignee: guid, assignee__exists: false), hydrate with include: "assignee,comments.author". The SKILL.md modeling rule — links are relations, not id fields — transfers untouched.
first() exists because (await list({limit:1})).objects[0] is the single most re-typed expression in the example apps, and forgetting the .objects envelope is the single most common generated bug. count stays un-wrapped: (await list({where, limit: 1})).total is already one honest line.
Live queries — watch()
The Firebase feature people actually mean when they say "Firebase" is onSnapshot: render a query, and the UI stays true as data changes. Gap #5 records that MorphDB has no realtime story and correctly prices server push as a serious build (SSE/WebSocket infrastructure, per-app fan-out, a changes feed). watch() takes the 80/20 that needs zero server change: re-run the query on an interval, diff, and only call back when something actually changed.
subscriptionconst stop = tasks.watch(
{ where: { done: false }, sort: "priority", order: "desc", limit: 50 },
({ objects, added, updated, removed, initial }) => render(objects),
{ interval: 2000, onError: (e) => console.warn(e) } // both optional
);
// later: stop();
Semantics, pinned down:
- Fires once immediately with the first result (
initial: true), then polls atinterval(default 2000 ms). - Diffs against the previous snapshot by
_guid+_updated_at; the callback runs only on change, with the full currentobjectsplusadded/updated/removedconveniences. - Pauses while
document.hidden; on tab return, resumes with an immediate poll. Background tabs cost the server nothing. - A failed poll calls
onError(default: one console warning) and backs off exponentially to a 30 s cap; a success resets the interval. A dead backend never busy-loops. stop()is idempotent and synchronous.
This is polling, and the spec says so out loud: freshness is bounded by interval, and N mounted watchers cost N list queries per tick. At localhost and small-hosted scale that's noise (the Figma clone already polls harder than this by hand, with none of the visibility or backoff behavior). If a real app ever needs sub-second push or hundreds of concurrent watchers, that's the server-push build from gap #5 — not a cleverer client loop.
The contract is transport-blind. Nothing in the signature — query in, snapshot-callback out, stop() back — names polling. When a server events endpoint exists, watch() feature-detects it and switches transport; application code and SKILL.md examples don't change a character. That is the same migration Firestore's onSnapshot has survived twice.
Browser-side init — the portable app file, everywhere
MorphDB already has a portable app format: morphdb.schema.json at the repo root (app key + every type), stood up by the idempotent CLI morphdb init — create if missing, merge additively if present, never destructive, with --reset as the only (interactive, CLI-only) wipe path. The SDK ships the browser-side twin:
self-provisioning static pageconst schema = await (await fetch("./morphdb.schema.json")).json();
const db = await morphdb.init(schema, { host: HOST });
// app registered if missing (409 swallowed), each type PUT with merge:true,
// returns a client already bound to the app key inside the file.
Same semantics as the CLI, minus the destructive path: there is no reset from the browser. A name clash converges the schema and keeps the data, every time.
This isn't speculative — it's the pattern this repo already runs in production twice: specs/comments.js hand-rolls exactly this bootstrap so the spec pages can store comments in a cloud MorphDB with no agent in the loop, and every example clone ships a morphdb.schema.json a static deploy could self-provision from. After P2, comments.js deletes its api() + ensureBackend() and becomes the SDK's first dogfood customer (§12).
Browser-side registration is possible because POST /app is open — which is already true today and already relied on. init adds convenience, not capability. The actual tightening (who may create apps, who may write schema) is MorphRules territory: its two-tier key split turns init from "anyone" into "secret-key holders", and the SDK seam for that is already reserved (§13-D9).
Schema stays the agent's plane. init is deliberately the only schema-touching call in the SDK, it is additive-only, and it takes a whole document produced by morphdb export-schema — not a field-by-field authoring API. The CLI remains the way schemas are made; init is how a finished schema travels.
Errors
Every non-2xx throws MorphDBError extends Error with status, code, and any extra fields from the server's envelope ({"error": {"code", "message", …}}). The server's error messages are deliberately didactic — "filter on an un-indexed field → here's the --index command to run" — so the SDK's prime directive is pass the message through verbatim. Wrapping or "friendlifying" them would destroy the best agent-teaching surface MorphDB has.
typed, branchabletry {
await tasks.get(guid);
} catch (e) {
if (e.code === "not_found") showEmptyState(); // e.status === 404
else throw e;
}
| code | status | Typical cause the message will name |
|---|---|---|
bad_request | 400 | filter/sort on an un-indexed field · un-declared field in a write · missing X-App-Key · type coercion refusal |
not_found | 404 | unknown app key · missing object/type · unmatched route |
conflict | 409 | app key already registered (swallowed inside init, surfaced everywhere else) |
method_not_allowed | 405 | wrong verb on a real path |
network_error | — | fetch itself failed — no HTTP happened |
The one place the SDK adds words instead of relaying them: a network_error against a loopback host appends "is the backend running? — morphdb status · morphdb start · morphdb logs". That's SKILL.md's debug tip moved into the failure itself, which is where an agent will actually read it. 204 returns null; no other response is reshaped.
Distribution & versioning
The file lives at morphdb/sdk.js as package data and every transport serves it at GET /sdk.js — a meta route like /help: no X-App-Key, no app data, safe to cache. Serving substitutes one __MORPHDB_VERSION__ placeholder so morphdb.version always equals the server's __version__, sends ETag (the version string) with Cache-Control: no-cache — browsers revalidate with a cheap 304 and upgrades apply on next load. The Lambda deploy inherits all of this for free since routing is transport-neutral; the file rides inside the zip.
This kills the classic SDK versioning problem structurally: the SDK arrives from the server it will talk to, so client and server cannot skew. There is no CDN pin to bump, no compatibility matrix, no "works with servers ≥ 0.4". One MorphDB, one matching SDK, always.
Types without a toolchain: the file opens with JSDoc @typedefs (ListOptions, ListResult, WatchEvent, MorphDBError). VS Code and TS-aware agents get IntelliSense from the raw file; an agent can also just curl /sdk.js and read the header — the SDK is self-documenting the way /help is.
Classic script over ESM, for now. Generated sites are overwhelmingly single-file HTML opened from disk or static hosting; <script src> + a global works in 100% of them, including file://. App code that wants top-level await wraps itself in <script type="module"> and uses the global. A dual ESM build adds a packaging step for a consumer that doesn't exist yet — deferred with npm (§14).
SKILL.md & docs impact — where the payoff lands
The SDK's leverage is only realized when the skill stops teaching the paste. SKILL.md §2 currently emits ~45 lines of client per site; it becomes:
skill.md §2, after<script src="http://127.0.0.1:8787/sdk.js"></script> <!-- or your MORPHDB_HOST -->
<script type="module">
const db = morphdb("my-cool-site");
// db.type(t): create / get / list / first / update / put / delete / watch
</script>
plus a short verb cheat-table replacing the long example block. Net effect per generated site: the boilerplate drops from ~45 pasted lines to 3, every call site sheds its string-assembly, and helper bugfixes ship to all future sites by upgrading one file in the package. The marker-comment convention gains one line (Data: morphdb.js SDK from /sdk.js) so later sessions know the site speaks SDK, not raw fetch.
Also updated in the same pass: README's frontend section (script-tag first, raw fetch demoted to "under the hood"), and the visual explainer's code snippet. The examples keep working untouched — raw fetch remains a supported wire forever; one clone migrates in P2 as the showcase diff ("−60 lines, −3 bug classes").
Implementation plan
| Phase | Ships | Notes |
|---|---|---|
| P1 — core | morphdb/sdk.js (client, host chain, 7 verbs, encoding rules, MorphDBError, raw, JSDoc) · GET /sdk.js route with version stamp + ETag · SKILL.md §2 rewrite · README touch |
~250 LOC of JS, ~25 of Python. Tests: pytest — route serves, version matches __version__, 304 on ETag; node smoke test (below). |
| P2 — composites | watch() · morphdb.init() · migrate specs/comments.js onto the SDK (deletes its hand-rolled api()/ensureBackend()) · migrate one example clone |
~120 LOC. comments.js is the dogfood gate: if the SDK can't replace it cleanly, the design is wrong. |
| P3 — blocked on others | db.auth.* + Authorization header once MorphRules lands · watch() SSE transport once a server events endpoint exists |
Both slot into reserved seams (§13-D9, §07); neither moves app-facing API. |
Testing shape. The SDK's logic is encoding + error mapping + two loops, so: (1) pytest asserts the serving contract; (2) a single tests/sdk_smoke.mjs, run by pytest via node against a live test server (skipped with a warning when node is absent), drives the real file end-to-end — create/list/filter/include/update/delete, a 400's code and message passthrough, watch seeing an external write, init converging twice. No JS test framework; plain assert.
Hold the line on the size budget: ≤ 400 lines, zero dependencies, one file. The moment sdk.js wants a second file or a build step, something from §02's non-goals is sneaking back in. The eject valve for scope pressure is db.raw(), not growth.
Decision log
| # | Chose | Over | Because |
|---|---|---|---|
| D1 | Classic script, one global | ESM / dual build | Works in every generated site including file://; modules can still consume the global. No consumer for an ESM build exists yet. |
| D2 | Served by the backend at /sdk.js | npm / CDN | Lockstep versioning by construction; zero install; hosted instances serve their own matching client. |
| D3 | Host inferred from the script tag | Required config object | The file's origin is the backend. Kills Firebase-style config ceremony; chain still honors MORPHDB_HOST and an explicit option. |
| D4 | Explicit appKey argument | Reading window.MORPHDB_APP | Identity belongs in app source, not ambient globals. (Host may fall back — an address is environment; a key is identity.) |
| D5 | One options object; where keys = wire keys (__gt, __in, …) | Chainable builder / nested operator objects | Zero translation between endpoint docs and SDK docs; fewest tokens to a correct call; no call-order trivia. |
| D6 | watch() by polling, transport-blind contract | Server push now / nothing | Zero server change for 80% of the value; the signature survives the SSE upgrade untouched (gap #5 stays open, honestly). |
| D7 | Data plane only, with init as the lone additive exception | Full schema API in the browser | The agent-CLI / frontend-HTTP split is MorphDB's core design; init only moves finished schemas (CLI parity, never destructive). |
| D8 | Server error messages passed through verbatim | SDK-authored "friendly" errors | The server's errors already teach the fix (index this field, declare that one); rewording them for agents is strictly worse. |
| D9 | Reserve an auth seam (headers option now, db.auth later) | Building login into v1 | MorphRules owns identity; the SDK pre-commits only to where it will plug in, so P3 is additive. |
| D10 | No offline cache, no optimistic writes, no retries on writes | Firebase-style sync engine | Localhost-scale tool, honest failure model; a retried write against a live backend is a duplicate-object generator. |
Open questions
- npm + ESM: publish
@morphdb/sdkonce a bundler-based consumer actually appears, or never? (Leaning: wait for the second real request.) - Node usage: the file is pure
fetchand would run in Node ≥ 18 if exported; worth aglobalThisguard + documentedimportpath now, or is that the npm question in disguise? - Pagination sugar: an async iterator (
for await (const t of tasks.iter(q))) is ~10 lines and matches how agents write loops — v1.1 or YAGNI? - Watch multiplexing: if one page mounts many watchers, coalescing same-type polls is possible client-side — but is that ever hit before server push should exist instead?
- Served types: also serve a
/sdk.d.tsgenerated-by-hand alongside, or is JSDoc-in-file enough for the editors that matter?
References
- The Gaps — #5 No realtime / subscriptions · #8 App keys are namespaces, not identity
- MorphRules — per-user authorization (the auth seam this SDK reserves)
morphdb/skill/SKILL.md— the drop-in client + querying conventions this SDK absorbsspecs/comments.js— the in-repo hand-rolled client + bootstrap; P2's dogfood target- Firebase
onSnapshot, supabase-js, PocketBase js-sdk — prior art detailed in §03