← stitchkitDocumentation

Generic application primitives

stitchkit/primitives is an optional browser-safe leaf. It declares values and policies; it does not own a database, transaction, scheduler, durable queue, transport or document generator.

Lifecycle and access

import { defineLifecycle, defineOwnerScope, definePermissionMatrix } from 'stitchkit/primitives';
import { z } from 'zod';

const lifecycle = defineLifecycle({
  name: 'document',
  states: ['draft', 'published'],
  roles: ['author'],
  transitions: {
    publish: {
      from: 'draft',
      to: 'published',
      by: ['author'],
      payload: z.object({ note: z.string() }),
    },
  },
});

const permissions = definePermissionMatrix({
  roles: ['reader', 'author'],
  operations: ['read', 'publish'],
  grants: {
    reader: { read: true, publish: false },
    author: { read: true, publish: true },
  },
});

Persist transition(...).event in the same application transaction as the next state. A state is an immutable branded value; transition() produces the next one. availableTransitions() and transition() read the same declaration.

defineOwnerScope resolves one owner from authenticated identity. Reading across owners is a separate acrossAllOwners(identity) call and permission. Pass the resulting branded OwnerScope to the data adapter. This proves the scope was explicit; Stitchkit cannot prove what SQL an arbitrary adapter emits. scanOwnerFilterRisks maps existing caller-named manual filters before a migration.

Exact values and deadlines

defineMoney(currency) stores integer minor units as a JSON string. Arithmetic keeps the currency literal in the type; division and rational shares return an explicit remainder. It does not own exchange rates.

defineUnitSystem stores exact canonical decimal strings. Every conversion is an application rational with an id. A converted QuantityProjection is derived and carries both its source and conversion id; a form/database value can be marked recorded. Whether a derived projection is persisted or recomputed is an application decision.

defineDeadlinePolicy requires an explicit now, IANA timezone and elapsed/calendar-day boundary. It returns the due instant, signed remaining days, overdue days and caller-owned category key. queryBoundary(now) returns instants suitable for a data-adapter predicate; reminders and escalations are not included.

Audit and delivery

Put an audit decision beside each operation:

meta: { audit: audit.record(z.object({ changed: z.array(z.string()) })) }
// or
meta: { audit: audit.omit('read-only projection') }

assertAuditDeclared(contract) refuses silence. createAuditRecord() validates the change and returns a DomainEvent; the same id/value can feed both the journal and delivery.

defineDomainEventDelivery().plan(event) is pure. Save that event and its destinations in the application’s transaction. After commit, call dispatch(event.id): the dispatcher accepts only an id and obtains actual work from DomainEventOutbox.claim. A transport returns delivered, retryable, terminal or unknown; the dispatcher calls the matching outbox transition. Only retry may schedule another claim, while unknown is held for inspection instead of guessed into a retry. The application owns retention and scheduling. There is deliberately no scheduler or distributed lease.

Exports

defineExportOperation supplies one input and one result schema to an ordinary contract endpoint. A small result can be ready with a ManagedFileRef; a larger result can be pending with the id of the application’s existing async operation. The input, scope, metadata and tool identity do not change. The application creates bytes and owns operation persistence; the managed-file boundary streams them.

Canonical JSON

import { canonicalJson } from 'stitchkit/primitives'

const bytes = canonicalJson({ '2': 'two', '10': 'ten', optional: undefined, nested: [1, null] }, {
  maxDepth: 20, maxNodes: 10_000, maxBytes: 64 * 1024,
})
// {"10":"ten","2":"two","nested":[1,null]}
// Native crypto or browser WebCrypto hashes these same UTF-8 bytes.

canonicalJson is the one serializer behind the digests: object keys sort by UTF-16 code unit, integer-like keys included; array order is kept; object members that are undefined are omitted. It refuses, before serializing, a top-level or array undefined, sparse arrays, functions, symbols, BigInt, non-finite numbers, negative zero, cycles, user prototypes, accessors and non-enumerable or symbol members. Validate your own schema first; the canonical boundary does not replace it.

Defaults: depth 100 (the maximum), nodes 100 000, UTF-8 bytes 1 MiB. Invalid options throw a Zod validation error. A refused value throws CanonicalJsonError, a TypeError whose reason is a CanonicalJsonRefusal; branch on the reason, never on the message:

reason Meaning
depth The value is nested deeper than maxDepth.
nodes The value has more nodes than maxNodes.
bytes The output is longer than maxBytes in UTF-8.
cycle The value contains a cycle.
negative-zero The value contains -0, which JSON cannot carry.
not-json The value is not bounded plain JSON: sparse arrays, non-finite numbers, non-plain objects, accessors.

depth, nodes and bytes are the caller’s limits being exceeded; the other three say the value is not JSON data at all.

import { CanonicalJsonError, canonicalJson } from 'stitchkit/primitives'

try {
  canonicalJson(payload, { maxBytes: 4096 })
} catch (error) {
  if (error instanceof CanonicalJsonError && error.reason === 'bytes') {
    // too large: reject the request
  } else throw error
}

The leaf is browser-safe: no Node, Bun or crypto, no agent-runtime/testing, no optional peers. Native and browser code run the same algorithm; hashing stays a platform adapter. A consumer that needs the same bytes moves only the serializer recursion and keeps its own proof schemas and verification boundary.