Skip to content

Latest commit

ย 

History

157 Commits

Folders and files

NameName
Last commit message
Last commit date
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

A Linear-like sync API on top of InstantDB

Since installing Linear for the first time, I was in awe of their sync engine. Not even necessarily for the user-facing benefits โ€” real-time sync, everything loading instantly โ€” but for the insane simplification on the developer experience side. No API juggling, no model mapping between the view, the business logic, and the data layer.

I dug through countless libraries and products and finally landed on InstantDB, probably the best backend of this new era of sync.

What they built is amazing, and a real step up. But it didn't provide the API I'd want as someone familiar with Domain Driven Design.

So I built it:


What you get

๐ŸŒ One codebase for server and client. The same model classes, the same methods, the same validation โ€” running in your backend, your web app, and your mobile app. Write a rule once and it's enforced everywhere.

๐Ÿงช Integration tests become unit tests. Swap one line and the in-memory client gives you a real RootStore โ€” real change tracking, real hydration, real relationships โ€” with no network and no database. Logic that used to need a provisioned test app and a slow, flaky integration suite is now a plain unit test that runs in milliseconds.

๐Ÿ›๏ธ A real domain model, not data bags. Entities are classes with methods and rules โ€” invitation.accept(), party.activeMembers โ€” validated in the constructor, private by default. Business logic lives in one place instead of being re-implemented in every component that touches the data, and an entity in an invalid state can't reach your UI.

๐Ÿ“ก Your domain model is your view model. Bind components straight to your entities โ€” no DTOs, no view models, no mapping layer to keep in sync. Change an object and the UI reading it updates itself, down to the individual property.

๐Ÿ“ฅ Load a slice, not the database. The choice used to be fetch-per-screen or sync everything โ€” a round trip on every navigation, or a client that chokes as history grows. Here you pick the slice: the last 100 messages of one chat hydrate into the same object graph, with the same classes, methods, and reactivity as a full sync. Startup stays fast however much history exists.

โšก Just edit your models. Set properties, push to arrays, reassign relationships โ€” plain object code. The ORM tracks what you changed and builds the transaction for you. You never write an update statement, diff anything by hand, or juggle IDs.

๐Ÿ”€ Property-level sync, not row-level. Only the fields you actually touched are written, so two people editing different properties of the same entity don't overwrite each other. Incoming updates merge the same way โ€” field by field, skipping anything the user is currently editing, so a sync can't wipe out what someone is typing.

โœ๏ธ Roll back transactions โ€” edit forms without draft copies. Let the user edit the real object, then keep it or throw the edits away. No cloned form state, no {...entity} spread, no merging a draft back into the real thing.

๐Ÿ“ In-memory fields โ€” no separate state for temporary edits. Transient state usually lands somewhere awkward: useState that dies when a virtualized row unmounts, or a Map<chatId, string> you thread through props and clean up by hand. @inMemory puts it on the entity instead โ€” chat.draftText follows the chat and vanishes with it.

๐Ÿ•ฐ๏ธ Automatic createdAt / updatedAt tracking.

๐Ÿ—‘๏ธ Soft delete, built in. Deleted entities vanish from every query automatically: no deletedAt filter to remember, no removed item reappearing in a list. delete() marks the row before removing it, so a failed delete leaves it invisible rather than half-gone โ€” and softDelete() keeps the tombstone when you need the history.

๐Ÿงฌ Real inheritance โ€” single-table or multi-table. CardPayment and BankTransfer share refund() and amount while keeping their own fields. Store the hierarchy in one table or one table per subclass โ€” your domain code is identical either way.

๐Ÿ’Ž Value objects that store themselves. Model Money, TimeRange, or EmailAddress as immutable types compared by value, then declare price: Money and let the ORM handle storage โ€” no serializing by hand, no priceAmount / priceCurrency bookkeeping leaking into your model. The type validates itself, so a negative price or a malformed email can't exist in the first place.

๐Ÿ“… Dates that mean what they say. Store a Temporal Instant, PlainDate, ZonedDateTime, or Duration straight on a model. A birthday stays a calendar date instead of shifting a day when the user crosses a timezone, and a moment keeps full precision instead of being rounded to milliseconds. No getTime() arithmetic anywhere.

๐Ÿงฉ Any type can be a column. Your domain isn't limited to what the database understands. Teach the ORM how to store a Decimal, a Color, a branded ID, or any type from a library โ€” once โ€” and use it as a field type anywhere. Temporal support is built exactly this way.

โฑ๏ธ Immediate or eventual consistency โ€” your choice, per change. Some things must land together: those go in one transaction. Others only have to happen eventually โ€” the confirmation email, the cleanup, the downstream update. Model both with the same objects and decide per change which one it is.

๐Ÿ“ฎ Built-in event pipeline. Write an event in the same transaction as the change that caused it, and a handler picks it up and runs it โ€” dispatched by event type, retried on restart. The transactional outbox pattern with no broker, no queue, no extra infrastructure.

๐Ÿ” Fully typed. Queries, relationships, and attributes are checked against your InstantDB schema. Rename a column and TypeScript tells you what broke.

๐Ÿ›ก๏ธ Permission-aware. Property-level permissions are modeled honestly: a restricted field is undefined, distinct from a genuinely absent null.

๐Ÿค– Loved by coding agents. Agents scatter and duplicate business logic all over the codebase, because they don't know where to put it. Now it's all in one place. And loading that context is cheap โ€” it's condensed into the model instead of spread across every file that touches the data.


Examples

๐ŸŒ One codebase for server and client

Construct a different adapter; everything above it is identical.

// server
const store = new RootStore({ db: new InstantDBAdminAdapter(adminDb) });

// client
const store = new RootStore({ db: new InstantDBClientAdapter(db) });

// same call, both places
await store.transaction(() => { invitation.accept(); });

๐Ÿงช Integration tests become unit tests

const store = new RootStore<AppSchema>({
  db: new InMemoryInstantDBSyncClient<AppSchema>({ schema }),
});
store.subscribeAll();

That's the whole setup โ€” no test app to provision, no credentials, no teardown. And it isn't a mock: change tracking, hydration, relationship wiring, and the transaction lifecycle are the real implementations, with only storage swapped.

๐Ÿ›๏ธ A real domain model, not data bags

@model("parties")
export class Party extends Model {
  @field()
  private _name: string;

  members: Member[] = [];

  constructor(name: string, id?: string) {
    super(id);
    if (!name.trim()) throw new Error("Party name cannot be empty");
    this._name = name;
    this.initTracking();
  }

  get name(): string { return this._name; }

  get activeMembers(): Member[] {
    return this.members.filter(m => m.isActive);
  }

  rename(name: string): void {
    if (!name.trim()) throw new Error("Party name cannot be empty");
    this._name = name;
  }
}

๐Ÿ“ก Your domain model is your view model

const PartyRow = observer(({ party }: { party: Party }) => (
  <Text>{party.name} ยท {party.activeMembers.length} going</Text>
));

No mapping step in between โ€” the component reads the entity directly.

๐Ÿ“ฅ Load a slice, not the database

await store.subscribeQuery({
  messages: {
    $: { where: { chatId }, order: { createdAt: "desc" }, limit: 100 },
  },
});

store.getAll(Message);   // the hydrated slice โ€” live, typed, full behavior

Query as narrowly as you like; whatever comes back becomes real models in the same graph.

โšก Just edit your models

await store.transaction(() => {
  invitation.accept();
  party.addMember(member);
});

๐Ÿ”€ Property-level sync, not row-level

// one client
await store.transaction(() => { post.title = "New title"; });

// another client, at the same time
await store.transaction(() => { post.body = "New body"; });

Both land. Only the touched field goes over the wire, and neither write carries the other's stale value.

โœ๏ธ Roll back transactions โ€” edit forms without draft copies

const draft = store.createTransaction();
draft.run(() => { party.rename(input); });

await draft.commit();   // Save
draft.rollback();       // Cancel โ€” every edited field back where it was

๐Ÿ“ In-memory fields โ€” no separate state for temporary edits

@model("chats")
export class Chat extends Model {
  @field()
  private _title: string;

  @inMemory("")
  draftText: string = "";      // never persisted
}
<TextInput
  value={chat.draftText}
  onChangeText={text => { chat.draftText = text; }}
/>

The draft rides along with the chat object, so it's still there when the user navigates away and back โ€” and no transaction is needed, because nothing is being saved.

๐Ÿ•ฐ๏ธ Automatic createdAt / updatedAt tracking

post.createdAt;   // Temporal.Instant, set on construction
post.updatedAt;   // bumped on every save

You never declare these.

๐Ÿ—‘๏ธ Soft delete, built in

await store.transaction(() => { post.softDelete(); });  // keep the tombstone

await store.queryModel(Post);  // deleted posts already excluded

๐Ÿงฌ Real inheritance โ€” single-table or multi-table

export abstract class Payment extends Model {
  abstract readonly modelType: string;

  @field({ type: Money })
  amount: Money;

  refund(): void { /* shared by every subclass */ }
}

@model("payments")
export class CardPayment extends Payment {
  get modelType(): "card" { return "card"; }
  last4: string = "";
}

@model("payments")
export class BankTransfer extends Payment {
  get modelType(): "transfer" { return "transfer"; }
  iban: string = "";
}

Both live in the payments table, told apart by modelType. Drop the discriminator and each subclass gets its own table instead โ€” the domain code is unchanged.

๐Ÿ’Ž Value objects that store themselves

@valueObject()
export class Money extends ValueObject {
  @field() readonly amount: number;
  @field() readonly currency: string;

  constructor(amount: number, currency: string) {
    super();
    if (amount < 0) throw new Error("Money.amount must be >= 0");
    this.amount = amount;
    this.currency = currency;
    Object.freeze(this);
  }
}
@field({ type: Money })
price: Money;              // โ†’ columns priceAmount, priceCurrency

๐Ÿ“… Dates that mean what they say

@field({ type: Temporal.PlainDate })
birthday: Temporal.PlainDate;      // a calendar date, not an instant

@field({ type: Temporal.Instant })
scheduledFor: Temporal.Instant;

๐Ÿงฉ Any type can be a column

class ColorCodec extends ColumnCodec<Color> { /* to/from column value */ }
registerColumnCodec(Color, new ColorCodec());

@field({ type: Color })
accent: Color;

โฑ๏ธ Immediate or eventual consistency โ€” your choice, per change

await store.transaction(() => {
  order.markPaid();          // immediate โ€” these two land together, or neither does
  inventory.reserve(order.items);

  new OrderPaid(order);      // eventual โ€” committed now, handled later
});

๐Ÿ“ฎ Built-in event pipeline

class SendReceipt implements EventPipelineHandler {
  async handle(event: OrderPaid, ctx: EventPipelineContext) {
    await email.sendReceipt(event.order);   // throws โ†’ stays pending, runs again
  }
}

new EventPipeline(store, [
  { EventClass: OrderPaid, handler: new SendReceipt() },
]).start();

Write the work, not the plumbing. No queue to provision, no broker to run, no outbox table to reconcile โ€” the event is a row that was already committed with the change that caused it.

๐Ÿ” Fully typed

const parties = await store.queryModel(Party);   // Party[], checked against your schema

๐Ÿ›ก๏ธ Permission-aware

email: string | undefined = undefined;   // undefined โ†’ hidden by permissions
bio: string | null = null;               // null โ†’ genuinely empty

About

A typed, reactive ORM for InstantDB with MobX-powered change tracking

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages