AbsoluteJS

Sync Packs

Convex Components without the lock-in. A sync pack bundles schema + permissions + readers/writers + collections + mutations + schedules as one npm package, registered with one engine.registerPack(...) call. The lineage is Convex Components; the difference is they're plain portable npm packages, not a runtime moat.

#Shipped packs

Seven first-party packs and one helper library live in the sync-packs monorepo. Each is its own independent npm package — install only what you use.

Presencev0.4.3
@absolutejs/sync-pack-presence

Per-channel live presence with heartbeat-driven membership, TTL cleanup, plus cursor and typing state patches. Typing carries its own deadline in state.typingExpiresAt, so stalled typists clear without a server pass.

Commentsv0.4.5
@absolutejs/sync-pack-comments

Threaded comments on host-side resources, with per-resource ACL, author-only edits, author-or-moderator deletes, optional CRDT bodies, an optional comments-with-author join, in-thread full-text search, and emoji reactions.

Digestv0.2.5
@absolutejs/sync-pack-digest

Scheduled per-actor digest emails — cursor-managed and transport-agnostic (you bring Resend / SES / Postmark), with a dryRun + onActorPreview mode for staging.

Notificationsv0.2.4
@absolutejs/sync-pack-notifications

Per-actor inbox with host-trusted notify, markRead, and markAllRead, an optional autoArchiveAfterDays cron, and a kindFilter param for slice subscriptions.

Favoritesv0.2.4
@absolutejs/sync-pack-favorites

Per-actor saved resources with idempotent favorite / unfavorite / toggle, an optional favorites-with-resource join, and a nullable pinnedAt timestamp for pinned-first sort.

Countersv0.1.5
@absolutejs/sync-pack-counters

Read-set-tracked live counters via defineReactiveQuery. Each counter is a separate reactive query whose compute reads through db; the engine re-runs and re-pushes when any touched table changes. Owns no tables — pure derived views.

Mentionsv0.1.4
@absolutejs/sync-pack-mentions

Parses @username from a body, writes per-actor mention rows, and fires an onMention hook the host uses to compose with other packs (typically notifications:notify). The composition seam, not a hardcoded dependency.

Triagev0.1.0
@absolutejs/sync-pack-triage

Per-actor unread and seen state, snooze that resurfaces on new activity, sticky dismiss, mute and unmute, restore, and bounded bulk triage over any host-owned resource.

Utilsv0.1.3helper library
@absolutejs/sync-pack-utils

Not a pack. Exports resolveActor, requireRowOwner, requireOwnerOrModerator, and createInMemoryStore — the patterns every pack repeats. New packs should import from here.

The full surface plus the worked-example demos live in the examples/sync app — every pack is wired across all four reactive frameworks (React, Vue, Svelte, Angular) so the same test loop validates them uniformly.

#One register call

A pack is a self-contained record. The engine's registerPack walks every field and dispatches to the matching engine.register* method — no new persistence path, no runtime indirection. Two registered packs cannot claim the same ownsTables entry; the engine throws PackTableConflictError if they try.

TS
// One install, one register call — the pack handles its own schema,
// permissions, readers/writers, collections, mutations, and schedules.
import { createSyncEngine } from '@absolutejs/sync/engine';
import { createPresencePack } from '@absolutejs/sync-pack-presence';

const engine = createSyncEngine();
engine.registerPack(
  createPresencePack({
    getActorId: (ctx) => ctx.session.userId,
    scope: (ctx) => ctx.session.workspaceId,
    heartbeatTtlSec: 30,
    // 0.3 — typing state with a TTL inside state.typingExpiresAt.
    typingTtlSec: 5,
  }),
);

// The engine now exposes:
//   - 'presence'              (collection — subscribe with { channel })
//   - 'presence:heartbeat'    (mutation)
//   - 'presence:cursor'       (mutation — patches state.cursor)
//   - 'presence:typing'       (mutation — patches state.typing + .typingExpiresAt)
//   - 'presence:leave'        (mutation)
//   - 'presence:cleanup'      (cron-fired schedule)

The pack is also surfaced in engine.inspect().packs for devtools.

#The factory shape

Every published pack ships as create<Name>Pack(config), never as a pre-built static record. Namespacing (the tablePrefix) and config injection (the app's getActorId, the scope) belong to the pack's own code, not to the engine. That's why the same pack can be registered twice on one engine with different prefixes, and why no two packs need a coordination layer to avoid stepping on each other's table names.

TS
// Every published pack ships as a FACTORY, not a static record. The
// factory takes the host's namespacing + auth shape and returns a
// SyncPack. This is why two packs can both want a 'users' table without
// conflict — the prefix scopes the names; the engine never rewrites them.
export type MyPackConfig<Ctx> = {
  // Owns-tables prefix; also applied to collection/mutation/schedule names.
  prefix?: string;

  // The ONLY contract the pack assumes about app ctx.
  getActorId?: (ctx: Ctx) => string | undefined;

  // Optional tenant / workspace scope.
  scope?: (ctx: Ctx) => string | null;

  // Pack-specific config goes here (TTL, cron, retries, ...).
  heartbeatTtlSec?: number;
};

// Hosts run two instances of the same pack on one engine via prefix:
engine.registerPack(createPresencePack({ prefix: 'docs_', /* ... */ }));
engine.registerPack(createPresencePack({ prefix: 'chat_', /* ... */ }));
// Tables: docs_presence + chat_presence — no collision.

#Composition rules

Packs compose via the subscription layer or through an explicit host-callback — never by calling each other's mutations directly. The pack subscribes to a sister collection, or it exposes a typed hook (e.g. mentions.onMention(({ mention }, ctx) => …)) and the host closes over the engine to wire the second pack inside that hook. Either way, cross-pack data flows through the change feed or through the host's explicit code — never through a hardcoded import. The mentions pack uses this seam to fire notifications:notify on every parsed @username, with neither pack importing the other.

TS
// Composition rule: packs read each other's collections, packs MUST NOT
// call each other's mutations. Cross-pack data flows through the change
// feed, not the call graph — that's the structural difference from
// Convex Components, and the reason a pack can be swapped or removed
// without rippling through every dependent pack.

// ─── (1) The subscription seam ─────────────────────────────────────────
// A docs feature can subscribe to the presence collection to render
// typing/cursor indicators without ever importing presence's mutations:
const inDoc = await engine.subscribe({
  collection: 'presence',
  params: { channel: docId },
  ctx,
  onDiff: rerenderTypingIndicators,
});

// ─── (2) The host-callback seam ────────────────────────────────────────
// Some packs (mentions, audit, …) need to fan OUT to other packs at
// write time. The pattern: the pack exposes a typed hook in its config,
// and the HOST closes over the engine to call a sister pack's mutation.
// The pack itself stays unaware of any other pack.
engine.registerPack(
  createMentionsPack({
    getActorId: (ctx) => ctx.session.userId,
    resolveActorId: async (username) => userIdByUsername(username),
    onMention: async ({ mention }, ctx) => {
      // Host wiring — mentions never imports notifications.
      await engine.runMutation(
        'notifications:notify',
        {
          actorId: mention.mentionedActorId,
          kind: 'mention',
          title: 'You were mentioned',
          body: mention.snippet,
          href: `/comments/${mention.sourceId}`,
        },
        { systemTrusted: true, userId: mention.authorId ?? undefined },
      );
    },
  }),
);

// A doc-pack handler that called `presence:heartbeat` directly would
// couple the two packs at runtime — the lock-in shape we avoid.

#Evolving a pack

Pack feature evolution happens inside the pack's npm semver — the engine doesn't need to know about new mutations or columns. Bump the version, ship the feature, and host code picks it up by upgrading the dependency. The favorites pack's 0.2 pinning surface is a worked example: a new nullable column on the owned row, three new idempotent mutations, no engine change, and clients sort pinned-first on the result.

TS
// Pack feature evolution stays inside the pack's npm semver — the
// engine doesn't need to know about new mutations. Example: favorites 0.2
// adds a pinning surface alongside the existing favorite/unfavorite/toggle.
engine.registerPack(
  createFavoritesPack<Ctx, Task>({
    getActorId: (ctx) => ctx.userId,
    joinResources: { table: 'tasks', hydrate: () => allTasks() },
  }),
);

// Mutations now on the engine (0.2):
//   - 'favorites:favorite'    { resourceKind, resourceId }
//   - 'favorites:unfavorite'  { resourceKind, resourceId }
//   - 'favorites:toggle'      { resourceKind, resourceId }
//   - 'favorites:pin'         { resourceKind, resourceId }   // NEW
//   - 'favorites:unpin'       { resourceKind, resourceId }   // NEW
//   - 'favorites:togglePin'   { resourceKind, resourceId }   // NEW

// Row shape adds a single nullable field; the client sorts pinned-first:
type FavoriteRow = {
  id: string;
  resourceKind: string;
  resourceId: string;
  actorId: string;
  createdAt: number;
  pinnedAt: number | null;   // NEW
};

#Authoring a pack

A pack is a plain data record returned by defineSyncPack(...). The fields mirror the engine's register* surface plus ownership metadata; the engine does the dispatching.

TS
// Anatomy of a pack — same shape as createSyncEngine's options, plus
// ownership metadata for conflict detection.
import {
  defineCollection,
  defineMutation,
  defineSchedule,
  defineSchema,
  defineSyncPack,
  field,
  type SyncPack,
} from '@absolutejs/sync/engine';

export const createMyPack = (config: MyPackConfig): SyncPack => {
  const table = `${config.prefix ?? ''}my_table`;
  const getActorId = config.getActorId ?? ((ctx) => ctx.userId);

  return defineSyncPack({
    name: '@yourorg/sync-pack-my-feature',
    version: '0.1.0',
    ownsTables: [table],
    // ...
    schemas: defineSchema({
      [table]: { fields: { id: field.string } },
    }),
    permissions: {
      [table]: {
        read: (ctx, row) => true,
        insert: (ctx, row) => row.authorId === getActorId(ctx),
      },
    },
    collections: [defineCollection({ name: table } as any)],
    mutations: [defineMutation({ name: 'my:do' } as any)],
    schedules: [defineSchedule({ name: 'my:tick' } as any)],
  });
};

The full design rationale — including the locked decisions on factory + injection, no engine-side name rewriting, and subscription-layer composition — lives in the syncPacks.design.md in the sync repo. Pack tests can use the @absolutejs/sync/testing subpath for createTestEngine, expectRejection, and runAsActor.