@owebeeone/grip-core
v0.3.0
Published
Framework-agnostic core runtime for GRIP (Generalized Retrieval Intent Provisioning).
Readme
@owebeeone/grip-core
Framework-agnostic core runtime for GRIP (Generalized Retrieval Intent Provisioning)
grip-core is the foundation library for building data producers (Taps) that can be consumed by any framework. It provides the core engine, type system, and tap implementations that power framework-specific bindings like grip-react and grip-vue.
Core Philosophy
GRIP decouples data sources from data consumers through a hierarchical context graph. Components request data by typed keys (Grips), and the system automatically resolves and delivers values from the most appropriate producer (Tap). This allows you to swap implementations—from simple local state to complex async API calls—without changing consuming code.
Core Concepts
Grip: The Typed Key
A Grip<T> is a typed, immutable identifier for a data channel. It defines what data is needed, not where it comes from.
import { Grip, GripRegistry, GripOf } from "@owebeeone/grip-core";
const registry = new GripRegistry();
const defineGrip = GripOf(registry);
// Define typed Grips with default values
const USER_NAME = defineGrip<string>("UserName", "");
const USER_AGE = defineGrip<number>("UserAge", 0);Tap: The Data Producer
A Tap is a producer that provides one or more Grips. Taps implement the Tap interface and declare which Grips they provide. The engine automatically connects Taps to consumers based on context hierarchy.
Drip: The Live Stream
A Drip<T> is a subscribable stream that delivers values to consumers. When a Tap publishes a new value, all subscribed Drips are notified.
Context: The Hierarchical Scope
A GripContext forms a Directed Acyclic Graph (DAG) that provides scope and parameter inheritance. Taps registered in a context become available to all descendant contexts, with closer providers taking precedence.
Grok: The Orchestrator
Grok is the central engine that manages the graph, resolves Tap-to-consumer connections, and coordinates value delivery.
Optional Sharing Integrations
Core has no Glade dependency. A tap's optional share field carries an opaque
metadata object owned by the application or integration. Tap<TShare> and
AtomValueTap<T, TShare> preserve that type; createAtomValueTap infers it.
Core does not inspect metadata fields or choose a sharing protocol. The slot
is readonly after construction, preserving its association with that type.
import { createAtomValueTap, GripOf, GripRegistry } from "@owebeeone/grip-core";
const COUNT = GripOf(new GripRegistry())<number>("Count", 0);
interface ApplicationShare {
channel: string;
revision: number;
}
function sharedCounter(share: ApplicationShare) {
return createAtomValueTap(COUNT, { initial: 0, share });
}For Glade, define the application's declaration using Glade's own Shape,
Authority, DomainAnchor, and ZoneKind types and require it in the
integration helper's argument. TypeScript then checks those values when the
application compiles. Only that integration imports both packages.
grok.listSharedTaps() discovers taps carrying metadata, but returns plain
taps with opaque metadata. An adapter must validate metadata before treating
it as a particular integration's declaration, or maintain typed registration.
The existing capture, apply, and subscribe hooks expose values independently
of the metadata vocabulary.
See release and migration guidance for the 0.3.0 changes.
Building Taps
Taps are the primary abstraction you'll work with in grip-core. There are three main types of Taps, each suited for different use cases.
1. AtomValueTap: Simple State Containers
Use AtomValueTap for local state, form inputs, or simple value holders. It combines value storage with a controller interface.
import { createAtomValueTap, GripRegistry, GripOf } from "@owebeeone/grip-core";
const registry = new GripRegistry();
const defineGrip = GripOf(registry);
const COUNT = defineGrip<number>("Count", 0);
const COUNT_CONTROLLER = defineGrip<AtomTapHandle<number>>("CountController");
// Create an atom tap with an initial value
const countTap = createAtomValueTap(COUNT, {
initial: 0,
handleGrip: COUNT_CONTROLLER, // Optional: expose controller interface
});
// Register with Grok
const grok = new Grok(registry);
grok.registerTap(countTap);
// Use the controller to update values
const controller = grok.query(COUNT_CONTROLLER, grok.mainHomeContext).get();
controller.set(42);
controller.update((prev) => prev + 1);2. FunctionTap: Derived/Computed Values
Use FunctionTap for values computed from other Grips. It supports both destination parameters (per-consumer) and home parameters (shared across all consumers).
import { createFunctionTap, GripRegistry, GripOf } from "@owebeeone/grip-core";
const registry = new GripRegistry();
const defineGrip = GripOf(registry);
const TEMP_CELSIUS = defineGrip<number>("TempCelsius", 0);
const TEMP_FAHRENHEIT = defineGrip<number>("TempFahrenheit", 32);
const UNIT_SYSTEM = defineGrip<"metric" | "imperial">("UnitSystem", "metric");
// Create a function tap that converts temperature
const tempConverterTap = createFunctionTap({
provides: [TEMP_FAHRENHEIT],
destinationParamGrips: [TEMP_CELSIUS], // Read from destination context
homeParamGrips: [UNIT_SYSTEM], // Read from home context
compute: ({ getDestParam, getHomeParam }) => {
const celsius = getDestParam(TEMP_CELSIUS);
const unit = getHomeParam(UNIT_SYSTEM);
if (unit === "imperial") {
return new Map([[TEMP_FAHRENHEIT, (celsius ?? 0) * (9 / 5) + 32]]);
}
return new Map([[TEMP_FAHRENHEIT, celsius ?? 0]]); // No conversion
},
});
const grok = new Grok(registry);
grok.registerTap(tempConverterTap);3. BaseAsyncTap: Asynchronous Data Sources
Use BaseAsyncTap (or factory functions) for fetching data from APIs, databases, or other async sources. It handles caching, cancellation, and request deduplication. Async taps can optionally expose request state via a stateGrip (loading/success/error/stale) and a controller via controllerGrip (manual retry/refresh/reset).
Default Behavior: By default, async taps output undefined when data is not ready (loading, error, idle states without valid cache). This propagates "not ready" state through dependency chains. Set keepStaleDataOnTransition: true to preserve stale data during transitions for UI stability.
import {
createAsyncValueTap,
LruTtlCache,
GripRegistry,
GripOf,
AsyncRequestState,
AsyncTapController,
} from "@owebeeone/grip-core";
const registry = new GripRegistry();
const defineGrip = GripOf(registry);
const USER_ID = defineGrip<string>("UserId", "");
const USER_DATA = defineGrip<{ name: string; email: string }>("UserData", {
name: "",
email: "",
});
const USER_DATA_STATE = defineGrip<AsyncRequestState>("UserDataState", {
state: { type: "idle", retryAt: null },
requestKey: null,
hasListeners: false,
history: [],
});
const USER_DATA_CONTROLLER = defineGrip<AsyncTapController>("UserDataController");
// Create an async tap that fetches user data
const userDataTap = createAsyncValueTap({
provides: USER_DATA,
stateGrip: USER_DATA_STATE, // optional: expose async request state
controllerGrip: USER_DATA_CONTROLLER, // optional: expose retry/refresh/reset
destinationParamGrips: [USER_ID], // Fetch based on user ID
requestKeyOf: (params) => params.get(USER_ID) ?? undefined, // Cache key
fetcher: async (params, signal) => {
const userId = params.get(USER_ID);
if (!userId) throw new Error("User ID required");
const response = await fetch(`/api/users/${userId}`, { signal });
return response.json();
},
cacheTtlMs: 5 * 60 * 1000, // 5 min cache
latestOnly: true, // Ignore out-of-order responses
historySize: 10, // keep recent state transitions (0 to disable)
retry: { maxRetries: 3, initialDelayMs: 1000, backoffMultiplier: 2 },
refreshBeforeExpiryMs: 5000, // schedule refresh before TTL expiry
// keepStaleDataOnTransition: false (default) - outputs undefined when not ready
// Set to true to preserve stale data during loading/error/idle states
});
const grok = new Grok(registry);
grok.registerTap(userDataTap);4. AsyncStreamMultiTap: Long-Lived Streams
Use createAsyncStreamMultiTap for subscriptions that stay open and emit many
updates (websockets, tick feeds, service stream events). Use
createAsyncValueTap / createAsyncMultiTap for one-shot fetch/response flows.
One stream task runs per request key. Destinations with the same key share
the stream; include destContext.id in requestKeyOf when a column needs its
own stream even with identical params.
import { createAsyncStreamMultiTap } from "@owebeeone/grip-core";
const priceTap = createAsyncStreamMultiTap({
provides: [PRICE, STATUS],
destinationParamGrips: [PRODUCT],
requestKeyOf: (params) => {
const product = params.get(PRODUCT);
return product ? `feed:${product}` : undefined;
},
subscribe: async (params, signal) => myWebSocketIterable(params, signal),
mapEvent: (_params, tick) =>
new Map([
[PRICE, tick.price],
[STATUS, tick.status],
]),
cacheLatest: true,
cleanupDelayMs: 1000,
retry: {
initialDelayMs: 1000,
maxDelayMs: 30_000,
stableResetMs: 10_000,
},
});See GRIP_STREAM_TAPS.md for lifecycle, retry, and when to choose stream vs
one-shot async taps. The grip-react-demo coin stream tab is a full example.
Keyed Contexts
Repeated UI regions (columns, panes) should reuse stable contexts instead of
creating a fresh createChild() every render.
// Simple isolated scope (weather column, editor pane)
const column = parent.getOrCreateChildContext("weather:left", (ctx) => {
ctx.registerTap(createAtomValueTap(LOCATION, { initial: "London" }));
});
// Source selection via TapMatcher (live vs mock provider per column)
const coin = parent.getOrCreateMatchingContext("coin:A", (ctx) => {
ctx.getGripHomeContext().registerTap(createAtomValueTap(SOURCE, { initial: "mock" }));
ctx.addBinding({
id: "live",
query: withOneOf(SOURCE, "live", 10).build(),
tap: liveFeedTapFactory,
baseScore: 5,
});
});Rules:
- The key is the cache identity;
context.idis for debugging only. - The init callback runs once per live keyed entry. Prefer stable named functions; do not close over render-varying values.
getOrCreateMatchingContextbuildsparent → home → presentation. Register column input atoms on home; read outputs from presentation (matching.getGripConsumerContext()). Destination params on parent-registered taps resolve through the presentation → home parent chain.
See GRIP_CONTEXT.md for home vs destination parameter semantics.
Parameter Types
Taps can declare two types of parameters that influence their behavior:
Destination Parameters
Read from the destination (consumer) context lineage. Changes invalidate only that specific destination.
In Function Taps: Use getDestParam(grip) to read destination parameters.
In Async Taps: Use params.get(grip) for unified access (checks destination first, then home), or params.getDestParam(grip) for destination-specific access.
// Function tap example
const tap = createFunctionTap({
provides: [OUTPUT],
destinationParamGrips: [LOCALE], // Each consumer can have different locale
compute: ({ getDestParam }) => {
const locale = getDestParam(LOCALE);
return new Map([[OUTPUT, formatForLocale(locale)]]);
},
});
// Async tap example
const asyncTap = createAsyncValueTap({
provides: USER_DATA,
destinationParamGrips: [USER_ID],
requestKeyOf: (params) => params.get(USER_ID) ?? undefined,
fetcher: async (params, signal) => {
const userId = params.get(USER_ID); // Unified access
// or: const userId = params.getDestParam(USER_ID); // Destination-specific
return fetchUserData(userId);
},
});Home Parameters
Read from the provider's home context lineage. Changes invalidate all destinations under that provider.
In Function Taps: Use getHomeParam(grip) to read home parameters.
In Async Taps: Use params.get(grip) for unified access (checks destination first, then home), or params.getHomeParam(grip) for home-specific access.
// Function tap example
const tap = createFunctionTap({
provides: [OUTPUT],
homeParamGrips: [THEME], // All consumers see the same theme
compute: ({ getHomeParam }) => {
const theme = getHomeParam(THEME);
return new Map([[OUTPUT, applyTheme(theme)]]);
},
});
// Async tap example
const asyncTap = createAsyncValueTap({
provides: API_CONFIG,
homeParamGrips: [API_BASE_URL],
requestKeyOf: (params) => params.getHomeParam(API_BASE_URL) ?? undefined,
fetcher: async (params, signal) => {
const baseUrl = params.getHomeParam(API_BASE_URL); // Home-specific
return fetchConfig(baseUrl);
},
});Unified Parameter Access
Async taps support unified parameter access via params.get(grip), which checks destination parameters first, then falls back to home parameters. This simplifies taps that can work with either parameter type.
// Works with either destination or home parameter
const tap = createAsyncValueTap({
provides: DATA,
destinationParamGrips: [ID], // Optional: can also be homeParamGrips
requestKeyOf: (params) => params.get(ID) ?? undefined, // Checks dest, then home
fetcher: async (params, signal) => {
const id = params.get(ID); // Unified access
return fetchData(id);
},
});Custom Taps
For advanced scenarios, you can create custom Taps by extending BaseTap or implementing the Tap interface directly.
import { BaseTap, Grip, GripContext, Drip, Grok } from "@owebeeone/grip-core";
class CustomTap extends BaseTap {
constructor() {
super({
provides: [MY_GRIP],
destinationParamGrips: [PARAM_GRIP],
});
}
produce(opts?: { destContext?: GripContext }): void {
// Your production logic here
const value = this.computeValue(opts?.destContext);
this.publish(new Map([[MY_GRIP, value]]), opts?.destContext);
}
private computeValue(ctx?: GripContext): any {
// Read parameters, perform computation, etc.
return /* ... */;
}
}Lifecycle Hooks
Taps have lifecycle hooks that are called by the engine:
onAttach(home: GripContext): Called when the Tap is registered at a contextonDetach(): Called when the Tap is unregisteredonConnect(destNode, grip, sink, helpers): Called when a consumer connectsonDisconnect(destNode, grip): Called when a consumer disconnects
Query System
The Query system allows declarative Tap selection based on context state. See GRIP_Query_System_Design.md for details.
import { QueryBuilderFactory, withOneOf } from "@owebeeone/grip-core";
const qb = new QueryBuilderFactory().newQuery();
const MODE = defineGrip<string>("Mode", "default");
const REGION = defineGrip<string>("Region", "us");
// Create a query that matches when MODE is 'live' AND REGION is 'us' or 'eu'
const query = qb()
.oneOf(MODE, "live", 10)
.anyOf(REGION, ["us", "eu"], 5)
.build();Documentation
GRIP_CONTEXT.md: Contexts, parameter topologies, and dual-context containersGRIP_GRAPH_OPS.md: Graph operations and mutation algorithmsGRIP_RESOLVER.md: Resolver and DAG caching specificationGRIP_Query_System_Design.md: Query system design and usageGRIP_ASYNC_TAPS.md: Async tap design and patterns
Installation
npm install @owebeeone/grip-coreTypeScript Support
grip-core is written in TypeScript and provides full type safety for Grips, Taps, and Drips.
Framework Integration
@owebeeone/grip-react: React hooks and components@owebeeone/grip-vue: Vue composables and components
The core library is framework-agnostic and can be used with any framework or even vanilla JavaScript.
