@syncropel/sdk
v0.8.0
Published
Integration SDK for the Syncropel protocol — TypeScript. Async client, grammar enforcement, canonical references, fail-open transport.
Maintainers
Readme
@syncropel/sdk
TypeScript SDK for the Syncropel protocol — emit content-addressed records, query threads, validate grammar, fail open on transport errors.
import { Client, Identity, Ref } from "@syncropel/sdk";
const client = new Client({
endpoint: "http://localhost:9100",
identity: Identity.static("did:example:my-app"),
});
const result = await client.emit({
act: "PUT",
kind: "music.catalog.track",
body: { title: "Glow", artists: ["Zonke"] },
refs: [Ref.musicTrack({ isrc: "USJI19810404" })],
thread: "music.library",
});
console.log(result.success, result.recordId);One call validates the grammar, builds the record envelope, retries on transient errors, and resolves cleanly on transport failures so a flaky network never crashes your handler.
Features
- Async client —
emit,query,queryThread,subscribe,intend,fulfill, plus reserved-kind helpers - Grammar enforcement —
body.kindvalidated before any network call - Canonical references — 11 community ref constructors (
@music.track,@code.file,@social.person, …) for cross-publisher correlation - Fail-open transport — every emit resolves to a result; transport errors never reject the promise
- Identity-aware — every record signed with the configured DID
- Zero runtime dependencies — uses the platform
fetch - Universal runtime — Node 18+, Deno, Bun, Cloudflare Workers, modern browsers
- In-memory
MockKernelfor tests at@syncropel/sdk/testing— no server needed
Install
npm install @syncropel/sdk| Runtime | Minimum |
|---|---|
| Node.js | 18 (built-in fetch + AbortController) |
| Deno | 1.30+ |
| Bun | 1.0+ |
| Cloudflare Workers | anywhere fetch is available |
| Modern browsers | anywhere fetch is available |
Ships compiled JavaScript + TypeScript declarations. ESM only.
Quickstart
Spin up a local Syncropel server, then emit your first record:
// hello.ts
import { Client, Identity, Ref } from "@syncropel/sdk";
const client = new Client({
endpoint: "http://localhost:9100",
identity: Identity.static("did:example:me"),
});
const result = await client.emit({
act: "PUT",
kind: "music.catalog.track",
body: { title: "Glow" },
refs: [Ref.musicTrack({ isrc: "USJI19810404" })],
thread: "music.library",
});
console.log("emitted:", result.recordId);
const records = await client.queryThread({ thread: "music.library" });
console.log(`thread has ${records.length} record(s)`);npx tsx hello.tsSee syncropel.com for installing the Syncropel server (spl) and a full hosted-vs-local guide.
Concepts in 60 seconds
- Record — the immutable, content-addressed unit. 8 fields; the SDK builds the envelope for you.
- Kind —
body.kindnames what the record is about, e.g.music.catalog.track. Follows a strict grammar:scope.category.entity[.version]. - Thread — a logical conversation / workflow. Records share a thread when they're part of the same activity.
- Actor — who emitted the record, expressed as a DID.
- Ref — a canonical pointer to a real-world entity (a song, a file, a person…) so records about the same thing correlate across apps.
API reference
Client
| Method | Purpose |
|---|---|
| emit({ act, kind, body, thread, refs?, parents?, dataType?, clock? }) | Primary emit. Returns Promise<EmitResult>. Grammar errors throw; transport errors resolve to success: false. |
| intend({ goal, thread?, parents?, kind?, body?, clock? }) | Open a thread. Emits INTEND. Generates a random thread id if none supplied; returns it on result.thread. |
| fulfill({ thread, summary, fulfills?, kind?, body?, parents?, clock? }) | Close a thread. Emits KNOW with body.summary + optional body.fulfills (id or list of ids). |
| emitCorrection({ corrects, revisedFields, reason, thread, ... }) | Reserved-kind helper for core.correction — supersede earlier records with revised values. |
| emitErasure({ erases, reason, thread, ... }) | Reserved-kind helper for core.erasure — mark records as erased (e.g. for compliance). |
| emitAlias({ oldKind, newKind, reason, thread, ... }) | Reserved-kind helper for core.alias — declare that one kind supersedes another. |
| emitScopeTransfer({ scope, fromPublisher, toPublisher, reason, thread, ... }) | Reserved-kind helper for core.scope_transfer — record ownership of a scope changing publisher. |
| emitScopeClaim({ scope, governancePolicy, thread, ... }) | Reserved-kind helper for core.scope_claim — claim a scope with a governance policy. |
| queryThread({ thread, limit?, since? }) | All records in a thread. Fail-open. |
| query({ kind?, actor?, thread?, since?, limit?, where? }) | Filtered query. At least one of kind / actor / thread required. |
| subscribe(thread, callback, options?) | Live record subscription via SSE. Returns a Subscription handle (close(), cursor, closed). Auto-reconnects with exponential backoff + jitter; resumes from last cursor. See examples/subscribe.ts. |
| health() | Server health probe. Fail-open. |
Constructor options: endpoint (string), identity (required), timeoutMs (default 30000), maxRetries (default 2), backoffMs (default 250), onEmit (observability hook), apiKey (sets x-api-key header), fetch (custom transport — useful for tests, proxies, observability wrappers).
Namespaces
Beyond the flat emit/query methods, the client groups the rest of the surface into namespaces:
| Namespace | Purpose |
|---|---|
| client.records | .create(emitOpts) (emit alias), .get(id), .richQuery({ filter, sort?, limit? }) — server-side filtered query. |
| client.threads | .list(), .records(id), .state(id), .project(id, format), .watch(id, opts?) — live AsyncIterable stream. |
| client.folds | .resolve<V>(name, cacheKey?), .watermark(name, cacheKey) — read any kernel fold through one typed method. |
| client.data | The data plane — files, blobs, materials (see below). |
| client.aitl | .pending(), .decide(id, { decision, reason? }) — actor-in-the-loop proposals. |
| client.namespaces | .list(), .create({ id, description?, policy? }). |
| client.federation | .peers(), .pair(peer), .sync(thread, peer?), .discoverPeers(), .mergeResults(). |
| client.invites | issue / list / preview / redeem / revoke / audit invitations. |
| client.expr | .eval({ expression, context, record? }) — evaluate a CEL expression. |
| client.graph | .query({ start, edges?, depth?, filter? }) — graph queries over the substrate. |
client.data — the data plane
Files, blobs, and materials. Unlike the record reads (which fail open), the data plane surfaces errors as SyncropelDataError — a dropped write never looks like a success.
| Method | Purpose |
|---|---|
| write({ path, data, contentType?, expectedHash? }) | One-call three-step write (init → chunked upload → commit). Returns { contentHash, sizeBytes }. expectedHash: omit = overwrite, a hash = compare-and-swap, null = create-only. |
| read(path) / readText(path) | Fetch a path's bytes / UTF-8 text. |
| readUrl(path) | Resolve a path to a short-lived blob URL. |
| list(path, { cursor?, limit? }) | Directory listing. |
| stat(path) | Node metadata: size, hash, content type, pin state, provenance. |
| mkdir(path) / mv(from, to) / rm(path, { recursive? }) | Tree mutations. |
| publish(path, { storageClass? }) | Promote a file to a durable, content-addressed artifact. |
| materials({ q?, limit? }) / node(path) | Typed, byte-free views over files (pickers, previews). |
| usage() / capabilities() / provenance({ … }) | Quota, self-describing limits, the "made by" feed. |
// Create, then safely edit with compare-and-swap.
const { contentHash } = await client.data.write({
path: "/files/notes.md",
data: "# hello",
contentType: "text/markdown",
});
try {
await client.data.write({ path: "/files/notes.md", data: "# edited", expectedHash: contentHash });
} catch (e) {
if (e instanceof SyncropelDataError && e.isConflict) {
// someone else wrote first — reload e.currentHash and retry
}
}
const text = await client.data.readText("/files/notes.md");Identity
| Form | Status |
|---|---|
| Identity.static(did) | Available |
| Identity.key(pathOrBytes) | Planned — throws if called today |
| Identity.federated(...) | Planned — throws if called today |
Ref — canonical reference constructors
| Constructor | Canonical | Schemes |
|---|---|---|
| Ref.musicTrack({ isrc? / spotifyId? / appleId? }) | @music.track | isrc:, spotify:, apple: |
| Ref.codeFile({ repo? / gitUrl?, path }) | @code.file | repo:, git: |
| Ref.opsIncident({ pagerduty? / linear? / url? }) | @ops.incident | pd:, linear:, url: |
| Ref.calEvent({ uid }) | @cal.event | uid: |
| Ref.socialPerson({ did? / email? / handle?+name? }) | @social.person | did:, email:, handle: |
| Ref.mediaPhoto({ sha256? / url? }) | @media.photo | sha256:, url: |
| Ref.mediaVideo({ youtube? / vimeo? / sha256? }) | @media.video | youtube:, vimeo:, sha256: |
| Ref.docText({ doi? / url? / platformId? }) | @doc.text | doi:, url:, platform: |
| Ref.finTransaction({ stripe? / plaid? / iso20022? }) | @fin.transaction | stripe:, plaid:, iso20022: |
| Ref.researchPaper({ doi? / arxiv? / s2? }) | @research.paper | doi:, arxiv:, s2: |
| Ref.coreThread({ id }) | @core.thread | id: |
Pass a list as refs: to emit(); the SDK merges them into body._refs. Two records anywhere in the network sharing the same canonical ref are joinable.
EmitResult
interface EmitResult {
success: boolean;
recordId?: string;
clock?: number;
error?: string;
retried: number;
kind: string;
act: string;
thread: string;
}SyncropelKindError
Thrown synchronously from validateKind() and every emit* method when body.kind violates the grammar. Subclasses Error; instanceof works across bundlers.
Fail-open contract
emit() never rejects its promise on network errors, 4xx, 5xx, or timeouts. Every call resolves to an EmitResult and your code inspects .success:
const result = await client.emit({ act: "PUT", kind: "music.catalog.track", body: {}, thread: "t" });
if (!result.success) {
// Transient failure — log and keep going.
console.warn(`emit failed: ${result.error} (retried ${result.retried})`);
}A flaky network can't bring down your handler. You decide whether to drop the failure, retry, or escalate.
Grammar errors are different. SyncropelKindError always throws — it indicates programmer error (an invalid body.kind), and no retry will fix it. Failing loud at development time is correct.
Observability hook
const client = new Client({
endpoint: "...",
identity: Identity.static("..."),
onEmit: (result) => {
metrics.increment("syncropel.emit", { tags: { success: result.success } });
},
});Fires on every success and failure. Hook exceptions are swallowed with a console.warn — a broken metrics pipeline can't break your emit path.
Grammar reference
Every record's body.kind follows the scope.category.entity[.version] grammar. The SDK validates at emit time:
| Kind | Valid? |
|---|---|
| music.catalog.track | ✓ — publisher scope, 3 segments |
| music.catalog.track.v2 | ✓ — versioned |
| @music.track | ✓ — community canonical (2-segment allowed for @<community> and core scopes) |
| core.alias | ✓ — reserved core primitive |
| dj.track_imported | ✗ — 2-segment publisher scope forbidden |
| Music.Catalog.Track | ✗ — uppercase forbidden |
| music.catalog.track.v2.foo | ✗ — 5 segments |
When a canonical ref exists for your domain, use it:
refs: [Ref.musicTrack({ isrc: "USJI19810404" })]This makes your record correlatable with every other music record across publishers. Without refs, nothing breaks — you lose cross-app correlation.
Testing your adapter
The SDK ships a canonical mock at @syncropel/sdk/testing — exercise your adapter end-to-end without running a server:
import { Client, Identity, Ref } from "@syncropel/sdk";
import { MockKernel } from "@syncropel/sdk/testing";
test("my adapter imports tracks", async () => {
const kernel = new MockKernel();
const client = new Client({
endpoint: "http://mock",
identity: Identity.static("did:test:me"),
fetch: kernel.fetch,
});
await myAdapter.importLibrary(client, tracks);
const emitted = kernel.recordsByKind("music.catalog.track");
assert.equal(emitted.length, tracks.length);
for (const r of emitted) {
const refs = (r.body as any)._refs as Array<{ kind: string }>;
assert.equal(refs[0].kind, "@music.track");
}
});Record IDs produced by MockKernel use the same SHA-256-of-canonical-JSON rule as the real server, so result.recordId matches what you'd see in production. The mock also enforces DuplicateClock on (thread, actor, clock).
Failure injection for fail-open coverage: kernel.failNextPostCall(n) and kernel.failNextGetCall(n).
Type-generation pipeline (v0.4.0+)
The SDK ships a manifest-derived type module at src/types-generated.ts —
20 typed interfaces produced from the kernel's published JSON schemas
(/v1/capabilities). These are the contract surface;
hand-written types in src/types.ts and src/client.ts reference them.
To regenerate after a kernel manifest change:
# 1. Regenerate the manifest snapshot from the kernel.
cargo run --bin export-manifest -p spl > sdks/manifest.json
# 2. Regenerate the SDK types from the new manifest.
cd sdks/typescript
npm run gen:types
# 3. Commit both manifest.json and src/types-generated.ts together.
git add ../manifest.json src/types-generated.tsDrift detection: npm run build runs npm run check:types first. If
the regenerated output differs from the committed types-generated.ts,
the build fails with a diff — preventing accidental drift between the
SDK and the kernel manifest.
Manifest version pin: package.json declares which manifest version
the SDK targets ("syncropel": { "manifest_version": "2" }). Mismatch
with the daemon's served manifest version is exposed via the
MANIFEST_VERSION constant — useful when a deployment runs an older
daemon than the SDK expects.
Versioning + stability
Independent semver. Patch releases ship freely; minor versions can change public behaviour with a CHANGELOG note.
| SDK version | Highlights |
|---|---|
| 0.4.x | Current. Type-generation pipeline + method expansion (client.threads, client.aitl, client.federation, client.namespaces, client.expr, client.graph, client.records namespaces; do/know/learn/capabilities flat methods; namespace constructor option). |
| 0.3.x | Subscribe / SSE record streaming with reconnect + resume. |
| 0.2.x | Adds query, search, and infer to the 0.1 surface. |
| 0.1.x | Foundation: async Client, grammar enforcement, canonical refs, reserved-kind helpers, MockKernel, fail-open transport. |
