JSPM

  • Created
  • Published
  • Downloads 3298
  • Score
    100M100P100Q108109F
  • License (MIT OR Apache-2.0)

The constraint-driven runtime for TypeScript. Declare what must be true — the runtime makes it happen.

Package Exports

  • @directive-run/core
  • @directive-run/core/adapter-utils
  • @directive-run/core/internals
  • @directive-run/core/migration
  • @directive-run/core/plugins
  • @directive-run/core/testing
  • @directive-run/core/worker

Readme

@directive-run/core

npm downloads bundle size CI license

Constraint-driven runtime for TypeScript. Declare requirements, let the runtime resolve them.

  • Auto-tracking derivations – computed values that track their own dependencies, no manual dep arrays
  • Typed constraint/resolver cycle – constraints declare what must be true, resolvers make it true
  • Events – typed event handlers with payloads for imperative actions
  • Plugin architecture – logging, devtools, persistence, and custom lifecycle hooks
  • Framework adapters – first-class bindings for React, Vue, Svelte, Solid, and Lit

Install

npm install @directive-run/core

Quick Start

import { createModule, createSystem, t } from "@directive-run/core";

const counter = createModule("counter", {
  schema: {
    facts: { count: t.number() },
    derivations: { doubled: t.number() },
    events: { increment: {}, reset: {} },
    requirements: {},
  },

  init: (facts) => {
    facts.count = 0;
  },

  derive: {
    doubled: (facts) => facts.count * 2,
  },

  events: {
    increment: (facts) => {
      facts.count += 1;
    },
    reset: (facts) => {
      facts.count = 0;
    },
  },
});

const system = createSystem({ module: counter });
system.start();

system.events.increment();
console.log(system.facts.count);     // 1
console.log(system.read("doubled")); // 2

Derived State

Derivations auto-track which facts they read. No dependency arrays, no manual subscriptions. Derivations can depend on other derivations for composition:

const app = createModule("app", {
  schema: {
    facts: { items: t.array<string>(), filter: t.string() },
    derivations: {
      filtered: t.array<string>(),
      count: t.number(),
      summary: t.string(),
    },
    events: {},
    requirements: {},
  },

  init: (facts) => {
    facts.items = ["apple", "banana", "avocado"];
    facts.filter = "a";
  },

  derive: {
    filtered: (facts) => facts.items.filter((i) => i.startsWith(facts.filter)),
    count: (_facts, derived) => derived.filtered.length,
    summary: (_facts, derived) => `${derived.count} items match`,
  },
});

Constraints and Resolvers

The constraint/resolver cycle is the core of Directive. Constraints declare what must be true. Resolvers declare how to make it true. The runtime connects them automatically.

import { createModule, createSystem, t } from "@directive-run/core";

const userModule = createModule("user", {
  schema: {
    facts: {
      userId: t.string().nullable(),
      profile: t.object<{ name: string }>().nullable(),
    },
    derivations: {},
    events: { login: { userId: t.string() } },
    requirements: { FETCH_PROFILE: { userId: t.string() } },
  },

  init: (facts) => {
    facts.userId = null;
    facts.profile = null;
  },

  events: {
    login: (facts, payload) => {
      facts.userId = payload.userId;
    },
  },

  constraints: {
    needsProfile: {
      when: (facts) => facts.userId !== null && facts.profile === null,
      require: (facts) => ({ type: "FETCH_PROFILE", userId: facts.userId! }),
    },
  },

  resolvers: {
    fetchProfile: {
      requirement: "FETCH_PROFILE",
      retry: { attempts: 3, backoff: "exponential" },
      resolve: async (req, context) => {
        const res = await fetch(`/api/users/${req.userId}`);
        context.facts.profile = await res.json();
      },
    },
  },
});

const system = createSystem({ module: userModule });
system.start();

// Dispatching login sets userId, which triggers the constraint,
// which emits the requirement, which the resolver fulfills automatically.
system.events.login({ userId: "u-123" });

Events

Events provide typed imperative actions with payloads. Define them in your schema and handle them with events:

events: {
  addItem: (facts, payload: { name: string; price: number }) => {
    facts.items = [...facts.items, { name: payload.name, price: payload.price }];
  },
  removeItem: (facts, payload: { id: string }) => {
    facts.items = facts.items.filter((i) => i.id !== payload.id);
  },
},

// Typed and autocompleted:
system.events.addItem({ name: "Widget", price: 9.99 });

Framework Adapters

Package Framework Reactivity Model
@directive-run/react React 18+ useSyncExternalStore hooks
@directive-run/vue Vue 3+ Ref / ShallowRef composables
@directive-run/svelte Svelte 4+ Readable stores
@directive-run/solid Solid.js 1+ Accessor signals
@directive-run/lit Lit 3+ ReactiveController classes

Subpath Exports

Import Purpose
@directive-run/core Core runtime – modules, systems, schema types
@directive-run/core/plugins Logging, devtools, persistence, observability, circuit breaker
@directive-run/core/testing Mock resolvers, fake timers, assertion helpers
@directive-run/core/migration Redux/Zustand/XState migration helpers
@directive-run/core/worker Web Worker support
@directive-run/core/adapter-utils Shared utilities for building framework adapters

Observability

Every system exposes a typed system.observe(observer) stream — one listener receives every observation event with full TypeScript narrowing on event.type. Source-publish, source-drop, fact-change, constraint evaluation, requirement lifecycle, resolver lifecycle, and effect runs all flow through the same channel.

const unsubscribe = system.observe((event) => {
  if (event.type === "source.publish") {
    log("source published:", event.id, event.eventName);
  } else if (event.type === "source.drop") {
    // Pairs with `source.publish` so observers see both halves of the
    // publish path without polling `inspect().sources[i].dropCount`.
    // `reason` is the shared `SourceDropReason` union.
    log("source dropped:", event.id, event.eventName, event.reason);
  }
});

For plugin authors, the same hooks are available on Plugin:

import type { Plugin, SourceDropReason } from "@directive-run/core";

const alerter: Plugin = {
  name: "drop-alerter",
  onSourceDrop(id, moduleId, eventName, reason: SourceDropReason) {
    if (reason === "coalesced") metrics.incr("source.coalesce", { id });
    if (reason === "blocked-event-name") logger.error({ id, eventName });
  },
};

The SourceDropReason union is the single source of truth for drop reasons — SystemInspection.sources[i].lastDropReason, the Plugin.onSourceDrop hook signature, and the source.drop observation-event variant all reference the same type. Adding a new reason in one place flows to all three at compile time.

Why Directive?

  • Declarative over imperative – describe what your system needs, not how to wire it up. Constraints and resolvers replace manual data-fetching orchestration.
  • Auto-tracking over manual subscriptions – derivations detect their own dependencies at runtime. No selector functions, no dependency arrays, no stale closures.
  • Constraint-driven over event-driven – instead of chaining events to coordinate async work, declare constraints that the runtime satisfies automatically with retry, batching, and error boundaries.
  • Framework-agnostic core – one state layer, five framework adapters. Move between React, Vue, Svelte, Solid, and Lit without rewriting your state logic.

Documentation

License

MIT