@syncropel/sdk
v0.2.0
Published
Integration SDK for the Syncropel protocol — TypeScript. Async client, grammar enforcement, canonical references, fail-open transport.
Downloads
298
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,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. |
| 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).
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).
Versioning + stability
Independent semver. Patch releases ship freely; minor versions can change public behaviour with a CHANGELOG note.
| SDK version | Highlights |
|---|---|
| 0.1.x | Current. Async Client, grammar enforcement, canonical refs, reserved-kind helpers, MockKernel, fail-open transport. |
| 0.2.x (planned) | Subscribe / SSE, batch emit, signing-key identity. |
| 1.0.x (planned) | Federated identity, typed kind parameter via literal-union autocomplete. |
