Ablo Collaboration Infrastructure

repository·main·Indexed 11 days ago

https://github.com/abloatai/ablo

Collaboration infrastructure for AI agents providing a unified API to manage concurrency, authority, and idempotency for multiple actors interacting with Postgres database rows. Includes the @abloatai/ablo SDK for schema definition and data operations, a CLI for project management and schema synchronization, and a Data Source contract for coordinating writes against customer-owned databases.

Tokens
213.9K
Snippets
563
Records
865
Agent score
48%

What's inside Ablo

  1. Overview of @ablo/agent

    main
    The @ablo/agent package provides internal adapters designed to compose Ablo 'rows' and 'claims' into AI SDK calls used within Ablo applications. It acts as a perception layer that enriches model calls with context derived from transaction data.
  2. Ablo Integration Overview

    main

    Ablo provides a unified, typed write path for all actors in your application—including React components, server actions, background workers, and AI agents. Instead of separate SDKs or mutation paths, every actor uses the same ablo.<model>.update(...) call.

    Key principles:

    • Unified API: All actors use the same model API; attribution is handled via credentials rather than the call site.
    • Identity-based Scoping: You do not manually pass organization or user IDs (e.g., org:123) in client code. The server derives permissions from the authenticated identity using identityRoles defined in your schema.
    • Scoped Agent Credentials: Agents use short-lived, scoped credentials rather than your master account API key.
    import Ablo from '@abloatai/ablo';
    import { credentialEndpointSuccessSchema } from '@abloatai/ablo/auth';
    import { defineSchema, model, z } from '@abloatai/ablo/schema';
    
    // schema -> ablo.<model>.list(...) -> ablo.<model>.update(...)
  3. What is @abloatai/humans?

    main

    @abloatai/humans is the interactive local-state runtime that powers Ablo applications. It transforms Ablo's ordered transaction stream into a WebSocket-backed local state system.

    Key features include:

    • Optimistic updates and rollback capabilities
    • Object identity and persistence
    • Presence tracking and live queries
    • MobX integration and React bindings

    Note for Application Developers: Most developers should not use @abloatai/humans directly. Instead, use the branded Ablo SDK (@abloatai/ablo), which delegates to this package. @abloatai/humans is intended for developers building Ablo client plugins or framework integrations.

  4. Stream the Ablo transaction log via Webhooks

    main

    Ablo provides a webhook system to stream its ordered transaction log to your own systems (databases, warehouses, search indexes, etc.) as signed events. This is a push mechanism to keep a durable copy of your data in sync with Ablo's log.

    Key Concepts

    • The Loop: While useAblo (WSS) is used for realtime, optimistic UI updates, Webhooks are used for durable, server-side data synchronization.
    • Ordering & Deduplication: Every event contains a syncId (a monotonic log position). You must use this ID to dedupe (skip already processed IDs) and order events to ensure your database remains consistent.
    • At-least-once delivery: Ablo guarantees delivery by advancing a per-endpoint cursor only upon receiving a 2xx response. If a delivery fails, Ablo retries with exponential backoff.

    Event Object Schema

    Each event in a batch contains:

    • type: "<model>.<verb>" (e.g., task.updated)
    • model: The exact model name from your schema.
    • objectId: The ID of the changed row.
    • data: The post-change row data, or null if the event is a deletion.
    • syncId: Monotonic log position (use this for ordering/deduping).
    • id: String representation of syncId.
    • createdAt: ISO commit timestamp.
    import type { AbloWebhookEvent } from '@abloatai/ablo/webhooks';
    
    // Example event structure
    interface AbloWebhookEvent {
      type: string;
      model: string;
      objectId: string;
      data: Record<string, unknown> | null;
      syncId: number;
      id: string;
      createdAt: string;
    }
  5. Understand the three layers of coordination

    main

    Ablo provides three layers of coordination, ranging from advisory awareness to strict mutual exclusion. Agents should use the minimum layer required for their task:

    LayerKindPurposeEnforcement
    Presenceawareness (push)Shows who holds what and why, live.None: Advisory only.
    Stale-contextsafety (pull)Prevents writing based on outdated reads.Yes: Rejects write at commit time.
    Claim + queuereservation (push)Reserves a row across a slow gap (Read $\rightarrow$ LLM $\rightarrow$ Write).Yes: Provides mutual exclusion.

    Note: Most simple write operations only require the Stale-context layer. Use Claim + queue only when the agent will hold a row across a slow, expensive gap.

  6. How change propagation and dependency chains work

    main

    Ablo uses a non-coercive model for change propagation. The engine handles routing and structural cascades, but it does not automatically recompute derived values. Instead, it signals that a change occurred and lets the actor decide how to respond.

    Propagation Mechanisms

    1. Routing: Every write is fanned out to all sync groups the row belongs to. Rows inherit their ancestors' groups (e.g., a block inherits its document and workspace groups).
    2. Structural Cascade: When a parent is deleted, the engine emits tombstones for all descendants so watchers see the entire subtree vanish.
    3. Value Recomputation: The engine does not recompute derived state (e.g., a rollup number). It surfaces the movement of the input, and the actor performs the recomputation.

    Modeling Dependencies (The Chain: A → B → C)

    To model a dependency where a change in A eventually affects C, you must use shared group membership and active actors:

    1. A writes to group {A, B}.
    2. B hears the signal via its subscription/track.
    3. B decides what the change means for its own state and writes to group {B, C}.
    4. C hears the signal from B's write.

    Important Design Considerations:

    • Actor Responsibility: The chain only moves as fast as the actors react. If actor B does not respond to the signal, the chain stops.
    • Avoid Cycles: Dependency cycles (e.g., C writing back to A) can cause infinite oscillations because each hop is a separate commit with its own stale check. Keep dependency graphs acyclic.
  7. Coordinate long-running work with `claim()`

    main

    To prevent multiple agents or background processes from clobbering each other during slow updates, use claim({ id }).

    How it works:

    • Serialization: If another participant holds the claim, claim() waits for them to finish, re-reads the fresh row, and then hands it to you. This ensures writers serialize instead of clobbering.
    • Non-blocking Reads: Normal reads still work while a claim is held. However, if you want a read to fail if a claim is active, pass ifClaimed: 'fail' to the read operation.
    • Automatic Release: Use the await using syntax (TypeScript/JavaScript) to bind the claim handle. The claim is automatically released when the scope exits, whether the work succeeded or threw an error.
    • Stale Context Protection: While holding a claim, if you attempt an update() but someone else changed the row in the meantime, Ablo will reject the update with an AbloStaleContextError.
    // Claim the row so other participants serialize behind us while we work.
    await using handle = await ablo.weatherReports.claim({
      id: 'weather_stockholm',
      description: 'checking_weather',
      ttl: '2m',
    });
    
    // Perform long-running work
    const weather = await weatherAgent.getWeather(handle.data.location);
    
    // Update the row using the fresh data
    await ablo.weatherReports.update({
      id: handle.data.id,
      data: {
        status: 'ready',
        forecast: weather.summary,
      },
    });
    
    // handle is automatically released here via 'await using'
  8. Prevent write collisions using claims

    main

    To prevent agents and humans from overwriting each other's work, use the claim method. A claim acts as a non-locking lease that serializes writers.

    Key behaviors:

    • Serialization: If another writer holds the row, claim waits for them, re-reads the fresh row, and returns it in claim.data.
    • Yielding: By setting queue: false, you can instruct an agent to yield immediately if a row is already held by someone else, rather than waiting in line.
    • Automatic Stale-Checking: When an update is performed while a claim is held (using await using), the SDK automatically attaches the claim's snapshot version as readAt and sets onStale: 'reject'. This ensures that if the row changes while your logic is running, the update is rejected with an AbloStaleContextError instead of clobbering the newer data.
    • Lifecycle: The claim handle is an AsyncDisposable. Use await using to ensure the claim is released automatically when the scope exits.
    // To yield instead of waiting for a holder:
    const acquired = await ablo.tasks.claim({
      id: taskId,
      queue: false,
      description: 'marking_done',
    });
    
    if (!acquired) return { status: 'yielded' };
    
    // Use await using to manage the AsyncDisposable lifecycle
    await using claim = acquired;
    
    // Updates made here are automatically stale-checked against the claim's version
    await ablo.tasks.update({
      id: claim.data.id,
      data: { status: 'done' },
    });
  9. How change propagation works in Ablo

    main

    Change propagation in Ablo follows a 'non-coercion' principle: the engine coordinates and reports changes but does not automatically recompute derived state. There are three ways a change propagates:

    1. Routing: Every row belongs to sync groups. A write to a row is automatically fanned out to all its ancestors' groups (e.g., a block edit stamps a delta for the block, document, and workspace groups). This is the delivery mechanism.
    2. Structural Cascade: When a parent is deleted (e.g., a workspace), the engine snapshots the subtree and emits tombstones for all descendants, ensuring watchers see the entire subtree vanish.
    3. Value Recomputation: The engine does not recompute derived values (e.g., a rollup total) automatically. Instead, it surfaces that a dependency has moved, and the actor (agent/user) is responsible for deciding the new value and writing it.

    To model a dependency chain (A → B → C), place A and B in one group, and B and C in another. When A changes, B receives a signal, performs its own write, and that new write then signals C.

  10. Understand Ablo Projects and Isolation

    main

    A project is the primary unit of isolation within an Ablo organization. Each project maintains its own independent schema, data planes, API keys, and branches. This allows different applications within the same organization to operate without interfering with each other's models, keys, or data rows.

    Key Hierarchy:

    • Organization: The top-level container.
    • Project: An isolated app environment (e.g., default or my-app).
    • Branches: Within a project, there is a protected production root branch and various development/preview child branches. Each branch has its own rows, schema artifacts, and credentials.
  11. Choose the correct Ablo client for your application

    main

    Ablo provides two distinct client types depending on your runtime requirements. Both clients share the same ablo.<model> vocabulary and request types, but their underlying implementations differ:

    1. Stateless HTTP Client: Use import { Ablo } from '@abloatai/ablo'. This is designed for agents, workers, cron jobs, and route handlers. It performs standard HTTP requests without maintaining a local state.
    2. Reactive Client: Use import Ablo from '@abloatai/ablo/client'. This is designed for interactive applications. It includes WebSocket synchronization, a local graph, presence support, offline persistence, and optimistic writes.
    3. React Bindings: Use import { AbloProvider, useAblo } from '@abloatai/ablo/react' to access React-specific hooks built on top of the reactive client.
    // Stateless HTTP client
    import { Ablo } from '@abloatai/ablo';
    
    // Reactive client
    import Ablo from '@abloatai/ablo/client';
    
    // React bindings
    import { AbloProvider, useAblo } from '@abloatai/ablo/react';
  12. Handle concurrent edits using claims

    main

    To prevent agents and humans from clobbering each other's edits, use the claim method. A claim acts as a non-locking lease that serializes writers.

    When you call claim, the SDK provides an AsyncDisposable handle. Using await using ensures the claim is released automatically when the scope exits. While a claim is held, any subsequent update calls are automatically protected by a stale-check: the SDK attaches the claim's snapshot version as readAt and sets onStale: 'reject'. If the row was modified by another party while the claim was held, the update will fail with an AbloStaleContextError instead of overwriting the new data.

    Use the queue option to control behavior when a row is already held:

    • queue: false: The claim call resolves to null immediately if another participant holds the row. This allows an agent to 'yield' and avoid fighting for the row.
    • queue: true (or omitted): The claim call waits for the current holder to release the row, then re-reads the fresh data and hands it to you.
    // Example of claiming a row and performing a protected update
    try {
      const acquired = await ablo.tasks.claim({
        id: taskId,
        queue: false, // Yield immediately if someone else has it
        description: 'marking_done',
      });
    
      if (!acquired) return { status: 'yielded' };
    
      // Use 'await using' to ensure the claim is released on scope exit
      await using claim = acquired;
    
      // This update is automatically stale-checked against the claim's version
      const updated = await ablo.tasks.update({
        id: claim.data.id,
        data: { status: 'done' },
      });
    
      return { status: 'done', task: updated };
    } catch (err) {
      if (err instanceof AbloClaimedError) return { status: 'yielded' };
      if (err instanceof AbloStaleContextError) return { status: 'stale' };
      throw err;
    }