Schema.org classes modeled as convergent replicated data types.
- Runtimes: Node >= 20, modern browsers, Bun, Deno, Cloudflare Workers, Edge Runtime.
- Module format: ESM + CommonJS.
- Required globals / APIs:
EventTarget,CustomEvent,structuredClone. - TypeScript: bundled types.
- Schema.org-shaped data that can be edited locally and merged through CRDT gossip.
- A single event-driven API across scalar properties, nested
CRText,CRSet,CRList, andCRMapproperties. - Runtime class inheritance that follows the implemented Schema.org hierarchy.
- Snapshot, delta, acknowledgement, and garbage-collection behavior aligned with the underlying Sovereignbase CRDT packages.
- Property-specific lexical validation only where Schema.org gives a clear format.
npm install @sovereignbase/schema-crdt
# or
pnpm add @sovereignbase/schema-crdt
# or
yarn add @sovereignbase/schema-crdt
# or
bun add @sovereignbase/schema-crdt
# or
deno add jsr:@sovereignbase/schema-crdt
# or
vlt install jsr:@sovereignbase/schema-crdtimport { CRPerson, CRPostalAddress } from '@sovereignbase/schema-crdt'
const alice = new CRPerson()
const bob = new CRPerson(alice.toJSON())
alice.addEventListener('delta', (event) => {
bob.merge(event.detail)
})
alice.givenName.insertAfter(-1, 'Jori')
alice.familyName.insertAfter(-1, 'Lehtinen')
alice.email.add('jori@example.test')
alice.jobTitle.add('Software engineer')
const address = new CRPostalAddress()
address.streetAddress = 'Example Street 1'
address.addressLocality = 'Helsinki'
address.addressCountry = 'FI'
address.postalCode = '00100'
alice.address.add(address.toJSON())
console.log(bob.givenName.valueOf()) // 'Jori'
console.log(bob.familyName.valueOf()) // 'Lehtinen'
console.log(bob.address.size) // 1import {
CRGeoCoordinates,
CRImageObject,
CRPlace,
CRPostalAddress,
CRWebPage,
} from '@sovereignbase/schema-crdt'
const place = new CRPlace()
place.name.insertAfter(-1, 'Sovereignbase Office')
place.url = 'https://example.test/place'
place.telephone = '+358 40 000 0000'
const address = new CRPostalAddress()
address.streetAddress = 'Example Street 1'
address.addressLocality = 'Helsinki'
address.addressCountry = 'FI'
address.postalCode = '00100'
const geo = new CRGeoCoordinates()
geo.latitude = '60.1699'
geo.longitude = '24.9384'
geo.addressCountry = 'FI'
const image = new CRImageObject()
image.contentUrl = 'https://example.test/place.jpg'
image.sha256 =
'39494d28efb226824110570e19a618751878d3e45da58a2a0bfadcc0c4672abd'
const page = new CRWebPage()
page.primaryImageOfPage.add(image.toJSON())
place.address.add(address.toJSON())
place.geo.add(geo.toJSON())
place.image.add('https://example.test/place.jpg')
place.mainEntityOfPage.add('https://example.test/place')
console.log(place['@type']) // 'Place'
console.log(place.geo.size) // 1import { CRPlace, type CRPlaceSnapshot } from '@sovereignbase/schema-crdt'
const source = new CRPlace()
let snapshot!: CRPlaceSnapshot
source.addEventListener('snapshot', (event) => {
snapshot = event.detail
})
source.url = 'https://example.test/market-square'
source.snapshot()
const restored = new CRPlace(snapshot)
console.log(restored.url) // 'https://example.test/market-square'This example assumes the snapshot is kept as a structured-clone-compatible
value. If you persist snapshots through JSON.stringify / JSON.parse, keep
property values JSON-compatible. Nested CRText, CRSet, CRList, and
CRMap properties emit their own routed snapshot and delta payloads; apply
those routed payloads with merge().
import { CRThing } from '@sovereignbase/schema-crdt'
const replica = new CRThing()
replica.addEventListener('delta', (event) => {
console.log('delta', event.detail)
})
replica.addEventListener('change', (event) => {
console.log('change', event.detail)
})
replica.addEventListener('snapshot', (event) => {
console.log('snapshot', event.detail)
})
replica.addEventListener('ack', (event) => {
console.log('ack', event.detail)
})
replica.name.insertAfter(-1, 'Example')
replica.sameAs.add('https://example.test/')
replica.url = 'https://example.test/thing'
replica.snapshot()
replica.acknowledge()Nested CRDT events are routed through their Schema.org property key. For
example, editing thing.name emits a delta whose detail is keyed by name,
and adding to thing.sameAs emits a delta keyed by sameAs.
import { CRThing } from '@sovereignbase/schema-crdt'
const alice = new CRThing()
const bob = new CRThing(alice.toJSON())
const frontiers = new Map<string, unknown>()
alice.addEventListener('delta', (event) => {
bob.merge(event.detail)
})
bob.addEventListener('delta', (event) => {
alice.merge(event.detail)
})
alice.addEventListener('ack', (event) => {
frontiers.set('alice', event.detail)
})
bob.addEventListener('ack', (event) => {
frontiers.set('bob', event.detail)
})
alice.name.insertAfter(-1, 'first')
alice.name.removeAfter(0, alice.name.size)
alice.name.insertAfter(-1, 'second')
alice.acknowledge()
bob.acknowledge()
alice.garbageCollect([...frontiers.values()])
bob.garbageCollect([...frontiers.values()])import { CRAction, CRIdReference, CRThing } from '@sovereignbase/schema-crdt'
const owner = new CRIdReference('urn:anbs:Person.alice')
const action = new CRAction()
action.agent.add({ '@id': owner['@id'], '@type': 'Person' })
action.object.add({ '@id': 'urn:anbs:Thing.document' })
const thing = new CRThing()
thing.owner.add({ '@id': owner['@id'], '@type': 'Person' })
thing.potentialAction.add({ '@id': 'urn:anbs:Action.read', '@type': 'Action' })
console.log(thing.owner.values()[0]?.['@id']) // 'urn:anbs:Person.alice'The examples use stable application identifiers in the form
urn:anbs:{@type}.{identifier}. Do not encode version numbers into @id
unless the application intentionally treats every version as a separate
Schema.org node.
Use typed JSON-LD references such as { '@id': '...', '@type': 'Person' }
where a Schema.org property requires a more specific class. Use plain
{ '@id': '...' } where the property accepts an untyped node reference.
import { CRThing } from '@sovereignbase/schema-crdt'
const thing = await CRThing.fromJSONLD({
'@context': 'https://schema.org',
'@id': 'urn:anbs:Thing.example',
'@type': 'Thing',
name: 'Example',
sameAs: ['https://example.test/'],
})
const jsonld = thing.toJSONLD()
const canonical = await thing.getCanonicalPresentation()
console.log(jsonld.name) // 'Example'
console.log(canonical.includes('https://schema.org/name')) // truefromJSONLD() is an async constructor-style import for compacted or expanded
Schema.org JSON-LD. It creates a fresh CRDT instance; it does not merge JSON-LD
into an existing replica. toJSONLD() exports the current live Schema.org
presentation as compacted JSON-LD. toJSON() remains the CRDT snapshot for
replication and persistence.
getCanonicalPresentation() validates the live presentation with
@adobe/structured-data-validator and returns jsonld.js URDNA2015 canonical
N-Quads for signing or hashing. That canonical form covers the live JSON-LD
presentation only. If an application needs to sign the whole CRDT state,
canonicalize toJSON() separately in the application protocol.
import { CRGeoCoordinates, SchemaCRDTError } from '@sovereignbase/schema-crdt'
const geo = new CRGeoCoordinates()
geo.latitude = '60.1699'
geo.longitude = '24.9384'
geo.addressCountry = 'FI'
try {
geo.latitude = '91'
} catch (error) {
if (error instanceof SchemaCRDTError) {
console.log(error.code) // 'VALIDATION_FAILED'
}
}Validation is property-specific. The underlying CRDT struct already checks broad runtime shape compatibility; schema-crdt only adds format checks where this package defines a clear lexical format, such as geo coordinates, ISO-like date/time and duration values, HTTP methods, and SHA-256 hex digests.
CRThingCRIntangibleCRStructuredValueCREnumerationCRStatusEnumerationCRIdReference
CRActionCRActionStatusTypeCREntryPointCRSoftwareApplicationCRDigitalPlatformEnumerationCRVirtualLocation
CRCreativeWorkCRMediaObjectCRImageObjectCRWebPageCRWebPageElementCRWebContentCRSpeakableSpecificationCRReviewCRDefinedTermSet
CREventCREventStatusTypeCREventAttendanceModeEnumerationCRSchedule
CRHowToCRHowToDirectionCRHowToItemCRHowToSectionCRHowToStepCRHowToSupplyCRHowToTipCRHowToToolCRItemListCRListItemCRBreadcrumbListCRItemListOrderType
CRPlaceCRAdministrativeAreaCRCountryCRPostalAddressCRContactPointCRGeoCoordinatesCRGeoShapeCRGeospatialGeometryCROpeningHoursSpecification
CRPersonCROrganizationCRAudienceCRDefinedTermCRSpecialtyCRPropertyValueCRQuantitativeValueCRMonetaryAmountCRLocationFeatureSpecificationCRRatingCRAggregateRating
- Scalar Schema.org properties use direct getters and setters.
- Natural-language text properties use
CRText. - Code, identifier, contact, coordinate, currency, unit, version, and address component properties stay scalar instead of pretending to be collaborative text.
- Multi-value unordered properties use
CRSet. - Ordered list properties use
CRList. - Custom extension properties can be wired through the
CRThingconstructor's CRDT property map. - Every class exposes
merge(),snapshot(),acknowledge(),garbageCollect(),toJSON(),clone(),values(),entries(), and iteration through the inheritedCRThingAPI.
Public field writes and incoming merge payloads can throw SchemaCRDTError:
VALIDATION_FAILEDCANONICALIZATION_FAILED
Validation is intentionally narrow. Deprecated and superseded Schema.org property aliases are omitted instead of being implemented as runtime aliases.
- Snapshots are detached structured-clone payloads keyed by Schema.org property name.
deltaevents are the gossip payloads to send to another replica'smerge().changeevents describe visible projection changes keyed by Schema.org property name.ackevents are acknowledgement frontiers forgarbageCollect().toJSON()returns the root struct snapshot, not a Schema.org JSON-LD document.- Nested
CRText,CRSet,CRList, andCRMapproperties expose their own snapshots through routed event payloads. - Direct
CRText,CRSet,CRList, andCRMapproperties retain their own CRDT semantics.
- The convergence target is the visible Schema.org-shaped projection.
- Nested CRDT property events are routed through their owning Schema.org key.
- A root class snapshot can hydrate scalar struct state in a fresh class instance.
- Routed nested snapshots and deltas can be merged into the corresponding nested CRDT instance.
- Acknowledgement frontiers are routed by property key so garbage collection can compact nested CRDT history without a second external index.
fromJSONLD()imports compacted or expanded JSON-LD into a new instance,toJSONLD()exports the live presentation, andgetCanonicalPresentation()canonicalizes that presentation for signatures or hashes.
npm run testWhat the current test suite covers:
- Coverage on built
dist/**/*.js:100%statements,100%branches,100%functions, and100%lines viac8. - Runtime export surface for every public constructor and every snapshot type.
- Runtime class hierarchy against the implemented Schema.org inheritance model.
- Exhaustive property mutation across every CRDT-backed class.
- Scalar setters, nested
CRText,CRSet,CRList, andCRMapproperty paths. - Snapshot hydration, merge, routed deltas, acknowledgements, and garbage collection.
CRIdReferencevalue snapshots, CRDT snapshots, events, listener removal, and merge.- Property-specific validator success and failure paths.
- Package root exports and source directory shape invariants.
- End-to-end runtime matrix for:
- Node ESM
- Node CJS
- Bun ESM
- Bun CJS
- Deno ESM
- Cloudflare Workers ESM
- Edge Runtime ESM
- Browsers via Playwright: Chromium, Firefox, WebKit, mobile Chrome, mobile Safari
- Current status:
npm run testpasses on Nodev22.14.0(win32 x64).
npm run benchThe benchmark suite measures schema-crdt orchestration paths rather than the primitive CRDT operations already covered by the lower-level packages.
Last measured on Node v22.14.0 (win32 x64):
| group | scenario | classes / keys | ops | ms | ms/op | ops/sec |
|---|---|---|---|---|---|---|
construct |
CRThing empty constructor |
1 | 250 | 27.44 | 0.11 | 9,111.02 |
construct |
CRWebPage empty constructor |
1 | 250 | 156.22 | 0.62 | 1,600.36 |
construct |
all schema classes empty constructors |
56 | 448 | 155.46 | 0.35 | 2,881.83 |
populate |
CRPerson full setter sweep |
77 | 50 | 261.44 | 5.23 | 191.25 |
populate |
CRImageObject full setter sweep |
147 | 50 | 413.33 | 8.27 | 120.97 |
populate |
all schema classes full setter sweep |
56 | 224 | 726.75 | 3.24 | 308.22 |
hydrate |
CRPlace hydrate populated snapshot |
55 | 50 | 14.07 | 0.28 | 3,553.48 |
hydrate |
CRImageObject hydrate populated snapshot |
147 | 50 | 46.82 | 0.94 | 1,068.03 |
replica |
CRThing merge populated snapshot |
14 | 50 | 5.91 | 0.12 | 8,456.23 |
replica |
CRWebPage merge populated snapshot |
134 | 50 | 48.82 | 0.98 | 1,024.22 |
events |
CRAction nested event routing |
18 | 750 | 43.03 | 0.06 | 17,431.40 |
lifecycle |
CRPerson acknowledge |
77 | 80 | 12.44 | 0.16 | 6,429.73 |
lifecycle |
CRImageObject garbage collect |
147 | 80 | 33.46 | 0.42 | 2,391.09 |
reference |
CRIdReference set / snapshot / hydrate |
1 | 250 | 17.82 | 0.07 | 14,031.78 |
validation |
format validators accepted values |
16 | 4000 | 328.01 | 0.08 | 12,194.79 |
validation |
format validators rejected values |
16 | 3250 | 880.75 | 0.27 | 3,690.02 |
Apache-2.0