GraphQL and search
The whole read/write surface also serves at one endpoint,
POST /api/v1/graphql. Filters take the same JSON grammar as
REST:
query ($f: JSON) {
entities(filter: $f, first: 20) {
nodes { id type title ... on Task { status } }
cursor
head
}
}
# variables:
# {"f": {"types": ["task.tasks.teild.dev"],
# "properties": {"status": {"eq": "open"}}}}
cursor is the same opaque keyset token REST returns (pass it back as after),
and head is the changelog head seq at the snapshot, for a gapless handoff to
watch. The changelog(from, filter, first) query resumes forward from a
transparent seq (the arg is from, not an opaque cursor) and echoes the last
seq back as from.
Every declared entity type gets a generated GraphQL type with its properties
as fields; interfaces span groups (Temporal, HasStatus: everything with
that trait or that state, so "everything with a
status, anywhere" is one query). Single-entity lookups are ref-addressed:
identity is the (type, id) pair, so entity takes both:
query { entity(type: "task.tasks.teild.dev", id: "kq3v9x2m41pf") { id title } }
Mutations are the seven, each addressing its target the same way:
mutation ($t: String!, $id: ID!, $in: JSON!) {
patch(type: $t, id: $id, input: $in) { id version }
}
# {"t": "task.tasks.teild.dev", "id": "kq3v9x2m41pf",
# "in": {"properties": {"status": "done"}}}
Generated names and scalars
A GraphQL type name is a pure function of the entity type's identity, never of
which other types happen to be installed, so the schema is deterministic and
installing one bundle can never rename another type. Bare names are reserved
for the shipped vocabulary (Task, Person, Calendarevent: the singular
with its first letter uppercased, nothing more). An installed
type is always group-prefixed: the name is the group's leading label,
TitleCased, an underscore, then the singular. Two extensions may declare the
same singular and stay distinct, because the full identity separates them:
page.notion.bundles.teild.dev is Notion_Page and
page.web.bundles.teild.dev is Web_Page, and neither can collide with the
other or with a shipped bare name. Interfaces follow the same rule: one per
trait that carries properties (a pure marker trait like bundleconfig adds
none), and one per distinct state-property name (HasStatus, HasDecision).
A type whose computed name would land on a structural name (Entity, Change,
Edge, Reference, a scalar, an interface) is refused at schema build with
a named error, never silently renamed.
version and seq (and the changelog resume seqs, head and from, and the
ifVersion precondition) are the Long scalar, a 64-bit signed integer
serialized as a JSON number. GraphQL's built-in Int is 32-bit, so a tenant's
version or seq counter would overflow past 2^31 (about 2.1 billion); Long
carries the full int64 range on the wire. A JavaScript client should read these
through a 64-bit-safe path if a counter can exceed 2^53, since a JSON number
past that loses precision in the browser's Number.
Property types render as their proper shapes. A repeated property is a GraphQL
list of its element type for every kind ([Int], [Float], [Boolean],
[String]), not a bare scalar. An object property (inline structured fields)
renders as the JSON scalar, lossless, rather than flattening to String. A
reference property is the Reference object ({group, type, id}).
Search
Search is one query, search(q, mode, types, k), served over GraphQL. It has
two arms:
- Lexical, on by default for every entity type. The title and every string-family property index into full-text search, weighted in three bands (title first, then declared string properties, then the rest), and
qtakes web-search syntax: bare words, quoted phrases,-exclusions. A property opts out withfts: false; secret-typed properties never index. - Semantic, strictly opt-in per property with
embed: true(the shipped vocabulary opts in long prose: message and mail bodies, task and event descriptions, transcripts, and media summaries). Opted-in text is chunked into overlapping windows and embedded asynchronously after commit, off a queue, so writes never wait on an embedding call. Vectors live in Postgres (pgvector) beside everything else, and the embedding model is deployment configuration, not schema.
mode picks lexical, semantic, or hybrid (the default): hybrid runs both
arms, normalizes each against its own best hit, and merges. Hits return with
per-arm raw scores so a caller can threshold. On a deployment with no embedder
configured, hybrid degrades to lexical and semantic reports an error rather
than pretending.
Two honest boundaries. There is no REST search endpoint: filtering is REST's
job (?filter=), searching is the GraphQL query's. And the substrate does
retrieval only: it returns typed entities with scores, and anything generative
built on top (a RAG loop, an assistant) is a client reading this API like every
other. Functions run on the shared runner and reach the same
search through a host call, under their declared read allowlist.
One ranking rule is built in: the built-in person carries a two-state
prominence machine (utility at birth, known once something promotes it,
an address-book sync or the owner), and search ranks utility people below
every known match, so the recruiter who emailed once never outranks a
friend.
Next: changelog and watch, the one log every write lands in.