Skip to content

Conversation

@ibilux
Copy link
Contributor

@ibilux ibilux commented Aug 23, 2025

Hello,

This aims to add transaction support to Trailbase clients:

  • Tried keeping the changes to minimal and close as possible with each client’s existing coding style.
  • Added transaction tests to each client library and all tests seem to pass.
  • Moved Go client tests above the Swift client tests in .pre-commit-config.yaml because Swift tests were taking forever to complete.
  • I did not bump any client versions yet.

The code definitely should be reviewed (because even thought all the high-level programming languages have some kind of an abstract similarities, but each one may have some language-specific improvements or performance idioms).

I can see that you are currently focused on wasm so this can wait whenever you have time for it.

Cheers,

@ignatz
Copy link
Contributor

ignatz commented Aug 23, 2025

Hey @ibilux , you're a machine!

I would love to get the transactions feature into the clients. Thanks for blazing the trail and keep pushing.

I don't currently have my computer, but I gave it a quick skim.
Practically, we should probably do them language by language. Let's start with whatever we're most comfortable with, settle on an API and then try to make it consistent in other languages (as long as it's reasonably idiomatic).

While currently grinding in another area, I've been thinking about this a bit in the back of my head. We've also touched a little bit on it already in terms of type-safety and such. I think you're proposed approach:

var batch = client.Transaction();
batch.Api("name").Create(json);
await batch.send();

is very valid. The other approach I was contemplating was to reuse the RecordApi "builders"

var api = client.Records("Name");
var op = api.CreateOp(json);
client.commit([op]);

(Phone coding is the best. Btw I only picked c# because of lexicographical sorting by suffix, please use whatever you like best. Maybe not python 😅).

Not sure if it makes much of a difference. Maybe if folks keep long-lived builders around 🤷‍♀️. Maybe we wouldn't have to wonder about lifecycle of the transaction object for failed transactions, repeated sends or post send appenditures 🤷‍♀️

I was also wondering if there's any value in making operations generics (probably not) or type-erase them right away as you suggest (probably makes sense). I could only see this make sense in the context of function composition, e.g.

void BuildCommonTxForMyBusiness(Client client, CreateOp<Type> mainOp) {
  client.commit([
    // ... aux changes
    mainOp,
  ])
}

In which case it would probably make sense to (also) have the API id in the op signature more so than the data type 🤷‍♀️

Again, thanks so much for being on top of this 🙏

@ibilux
Copy link
Contributor Author

ibilux commented Aug 25, 2025

Hello @ignatz,

Practically, we should probably do them language by language. Let's start with whatever we're most comfortable with, settle on an API and then try to make it consistent in other languages (as long as it's reasonably idiomatic).

I started with TypeScript, and it felt perfect at first. But once I tried to mirror it in other clients, I had this moment of: “oooh man, did I just over-engineer this?!”

(please use whatever you like best. Maybe not python 😅).

Definitely not python or swift 😅.

Not sure if it makes much of a difference. Maybe if folks keep long-lived builders around 🤷‍♀️. Maybe we wouldn't have to wonder about lifecycle of the transaction object for failed transactions, repeated sends or post send appenditures 🤷‍♀️

While writing the code I was thinking about the lifecycle concerns too (failed transactions, reuse, appending after send, and especially repeated sends). I even wondered whether we should clear the operations array after a successful commit ?

The other approach I was contemplating was to reuse the RecordApi "builders":

var api = client.Records("Name");
var op = api.CreateOp(json);
client.commit([op]);

I was also wondering if there's any value in making operations generics (probably not) or type-erase them right away as you suggest (probably makes sense). I could only see this make sense in the context of function composition.

I also leaned toward type-erased operations for my suggested approach (generics felt like overkill). However, your suggestion of reusing the RecordApi builders for operations is more appealing because of the type-safety, but I worry it could get confusing if both Create (immediate) and CreateOp (transactional) live side by side under the same API. For example:

let api = client.Records("Name");  
let op1 = api.CreateOp(json); // ✅ belongs in transactions  
let op2 = api.Create(json);   // ❌ not for transactions  

client.commit([op1]);         // ✅ works  
client.commit([op1, op2]);    // ❌ invalid, but looks plausible

There is possible solutions to make the intent clearer:

  1. Instead of putting both on RecordsApi, you could split them on separate “namespaces”. For example ops() or transactions() only returns operation-producing methods, while records() only returns immediate API methods. No accidental mixing.
// direct API calls
client.records("users").create({ name: "Alice" });
// operation builders
client.ops("users").create({ name: "Alice" });
  1. Stick with one namespace, but enforce a naming scheme. This way it’s unambiguous that one is a “builder” not an “action”.
api.create({ ... })         // executes immediately
api.buildCreate({ ... })  // builds an Operation
  1. Using strong typing guardrails to block invalid cases so api.create(json) wouldn’t even be assignable to an Operation. This would work for TypeScript typing but no sure about other languages.
type Operation = { ... };
client.commit([Operation, ...]);  // only accepts Operations
  1. Another option could be to make the transactional nested entry point live inside records(), something like:
let op1 = client.records("users").operation.create({ name: "Alice" });
client.commit([op1]);

@ignatz
Copy link
Contributor

ignatz commented Aug 25, 2025

While writing the code I was thinking about the lifecycle concerns too (failed transactions, reuse, appending after send, and especially repeated sends). I even wondered whether we should clear the operations array after a successful commit ?

👍

  1. Using strong typing guardrails to block invalid cases so api.create(json) wouldn’t even be assignable to an Operation. This would work for TypeScript typing but no sure about other languages.

This should be the case independent of where we put the API (below). "List of operations" will be supported by any typed language (even python's annotations :hide:)

To sum up your proposals:

  1. Have a parallel transactions("name")/ops("name") API to records("name").
  2. Have all on RecordApi, i.e. the immediate execution and the operations building.
  3. Nest the operations building on another API inside RecordApi.

I think they're all workable. Just thinking out loud, I could see a slight advantage for (1) and (3) in the context of mocking/testability. I could see a slight advantage for (2) and (3) if you want to pass RecordApi objects around and want to use them in various contexts (or even conditionally, e.g. use a transaction under some condition and otherwise execution .... though with the current proposal one would additionally need a client object to "commit" the transaction).

There's a 4th backwards-incompatible option (which is fine if we thought this is the way to go), where we'd make operations executable, i.e.

const api = client.records("myapi");
const op = api.create(/*...*/);
if (useTx) {
  await client.commit([op]);
} else {
  await op.execute();
}

At the moment I can't see a clear winner. With a good argument I could be swayed towards any of the options. Do you have a preference?

Thanks 🙏

@ignatz
Copy link
Contributor

ignatz commented Aug 25, 2025

Having simmered over this a little more, I sort of like the 4th option because it completely eliminates duplication. However, it introduces new issues/asymmetries, i.e. there are no read/list operations at least for transactions. We could have client-side read/list operation representations, however they shouldn't be accepted by client.commit([...ops]). I'm sure we could make it work; it's trivial in typescript with union types and we could carefully craft hierarchies for the other languages... just another consideration I wanted to share

@ibilux
Copy link
Contributor Author

ibilux commented Aug 25, 2025

At the moment I can't see a clear winner. With a good argument I could be swayed towards any of the options. Do you have a preference?

I was hoping you might provide some compelling arguments as you usually do 😅 — you always think ahead of all possible outcomes.

I think we shouldn't try to squeeze both records and transactions into the same surface. It feels cleaner to separate them into their own dedicated namespaces.

const tx = client.transaction();  
tx.records("users").create(record);  
tx.records("posts").update(id, data);  
await tx.commit(); // or tx.execute()

Alternatively, instead of letting the transaction builder manage the lifecycle directly, we could separate the builder from the commitment, like:

const tx = client.transaction();  
const op1 = tx.records("users").create(record);  
const op2 = tx.records("posts").update(id, data);  
await client.commit([op1, op2]);

@ignatz
Copy link
Contributor

ignatz commented Aug 25, 2025

I was hoping you might provide some compelling arguments as you usually do 😅

Appreciated

you always think ahead of all possible outcomes.

I wish 😅

I think we shouldn't try to squeeze both records and transactions into the same surface. It feels cleaner to separate them into their own dedicated namespaces.

I think that's a good goal. To tally up all discussed options that meet this goal (I guess the previous (2) and (3) wouldn't fit) and avoid Transaction-object lifecycle concerns:

  1. Have a symmetric transactions("name")/ops("name") API mirroring records("name")
  2. Have a client.commit([client.transaction() .records("users").create(record)]) API (your latest proposal).
  3. Have operations always be deferred and add an ~execute method.

I'm going to say that (1) and (2) are virtually the same if you fold .transaction().records("name") into .transaction("name"). Maybe you had some use-case in mind?

I've been careful not to push too hard for (3) because it would be the biggest change (not even that much more work but also for users). I do think, it's probably the most intuitive, i.e. you build and operation and then you either execute it right away or you add it to a transaction. Rather than using two different APIs.

Similarly, there's currently a bulk creation, which isn't a transaction. We could do away with it entirely and have an await client.execute([op, ... ], { transaction = false}) API. In other words we'd have fewer APIs and just different execution modes: single, bulk, transaction.

(EDIT: looking at prior art many SQL client implementations do have transaction objects. However, they're needed since a transaction isn't bulk, e.g. you'd have a SELECT and then conditionally UPDATE and then based on that do something else... in our case, we don't have that. Transactions are always bulks (no conditionals), thus using an array/list may make a lot of sense)

@ibilux
Copy link
Contributor Author

ibilux commented Aug 26, 2025

I'm going to say that (1) and (2) are virtually the same if you fold .transaction().records("name") into .transaction("name"). Maybe you had some use-case in mind?

Yes, in this case .transaction("name") would be better.

I've been careful not to push too hard for (3) because it would be the biggest change (not even that much more work but also for users). I do think, it's probably the most intuitive, i.e. you build and operation and then you either execute it right away or you add it to a transaction. Rather than using two different APIs.

Similarly, there's currently a bulk creation, which isn't a transaction. We could do away with it entirely and have an await client.execute([op, ... ], { transaction = false}) API. In other words we'd have fewer APIs and just different execution modes: single, bulk, transaction.

The only downside here is the major breaking change and the asymmetry around read/list.

If we go with deferred operations, each operation could be executed immediately via op.execute(), or composed into an array for bulk/transactional execution client.execute(operations: DeferredOperation[]). For example:

export class DeferredOperation {  
  constructor(  
    private client: Client,  
    private apiName: string,  
    // private recordId: string  
    // private record: Record<string, unknown>  
  ) {}

  // Immediate execution  
  async execute(): Promise<string> {  
    const response = await this.client.fetch(...);  
    return (await response.json()).ids[0]; // Or should we return an array?  
  }

  // For transaction/bulk execution  
  toOperation(): Operation { // or toJSON()  
    return {  
      // ...  
    };  
  }  
}
const api = client.records("users"); 
const op = api.create(record); // Always deferred

// Either execute immediately
await op.execute();
// OR add to transaction
await client.execute([op], { transaction: false });

If we adapt this model, RecordApi would return deferred operations:

export interface RecordApi<T = Record<string, unknown>> {
  create(record: T): DeferredCreateOperation;
  update(id: string | number, record: Partial<T>): DeferredUpdateOperation;
  delete(id: string | number): DeferredDeleteOperation;
}

The open question is how to handle read and list:

  • Should they remain immediate (since they don’t really participate in transactions)?
export interface RecordApi<T = Record<string, unknown>> {
  // ... Deferred Create/Update/Delete Operations
  // Read operations remain immediate since they don't participate in transactions
  read<T = Record<string, unknown>>(id: string | number): Promise<T>;
  list<T = Record<string, unknown>>(opts?: ListOptions): Promise<ListResponse<T>>;
}
  • Or should they also return deferred operations with op.execute()? If so, what would the return contract look like — still Promise<T> but wrapped inside an operation?

Another open issue: bulk mode semantics.

  • Do we allow bulk for all operations (create/update/delete), or just bulk create?
  • If we support all, does the client execute each operation individually and merge the results (e.g. collecting ids), or should the server expose a dedicated bulk endpoint to handle this efficiently?

✓ My personal take on this is:

  • Make all writes deferred and keep reads immediate (not deferred). For semantics, UX and simplicity.
  • Allow bulk execution of any write operations (C/U/D) for consistency (on the server-side would be more efficient, but we can do it in client-side).
  • For immediate execution via op.execute() return one value, and for bulk execution via client.execute(op[]) return an array.

@ignatz
Copy link
Contributor

ignatz commented Aug 27, 2025

Thanks for the write-up, its' very much appreciated and super useful 🙏

✓ My personal take on this is:

Make all writes deferred and keep reads immediate (not deferred). For semantics, UX and simplicity.

I can very much see your point: if you can't use the deferred operations for anything why have them? That said, I'm note sure if the asymmetry is actually helping semantics, UX or simplicity. Simplicity on our end for sure but for users? I worry that for users it will mostly stick out as an inconsistency - user-case or not. (Independently, one could try to fabricate uses, where users build higher level abstractions or persist read operations to then periodically carry them out,... arguably I'm scraping the bottom of the barrel here - I'm mostly wondering about how intuitive and pleasant the APIs would be. Anything that's not intuitive requires extra documentation).

Allow bulk execution of any write operations (C/U/D) for consistency (on the server-side would be more efficient, but we can do it in client-side).

Agreed. I would suggest to keep this in mind when designing the API but postponing the implementation. I.e only support create for now and then support the rest in the future. Just to keep the scope on transactions for now.

For immediate execution via op.execute() return one value, and for bulk execution via client.execute(op[]) return an array.

I love this. This will set us op to get rid of CreateBulkResponse and support updates/deletes in the future, simply mapping inputs to outputs 1:1 by index. Very intuitive.

To extend your code above, I could imagine something like:

class DeferredOperation<ResponseType> {  
  constructor(private api: RecordApi<ResponseType>) {}

  // Immediate execution  
  async query(): Promise<ResponseType>;
}

class DeferredMutation<ResponseType> extends DeferredOperation<ResponseType> {
  // For transaction/bulk execution  
  toJson(): Operation;  
}

export class CreateOperation<T> extends DeferredMutation<RecordId> {
  // ...
}

export class ReadOperation<T> extends DeferredOperation<T> {
  // ...
}

class Client {
  // ...

  // Only supports create for now. Happy to name this bulk or differently
  async create(ops: CreateOperation<unknown>[]);

  // Happy to rename this
  async execute(ops: DeferredMutation<unknown>[]);
}

// ....

const simpleApi : RecordApi<Simple> = client.records("simple");
const otherApi : RecordApi<Other> = client.records("other");

const results = await client.transaction([
  simpleApi.create({ simple: 5, /* ... */ }),
  otherApi.update(id, { other: "hi", /* ...*/ }),
]);

const simple : Simple = await simpleApi.read(results[0].id).query();

I don't hate it 😅 . One thing that jumps out after jamming, is that we need two different methods client.create() and client.execute() if we postpone non-transaction bulk operations. Otherwise this would simply become: client.execute([...], { transaction: false }). That said, we can always add this in the future (that's also why I chose the hyper-specific name client.create() thinking that we probably want to remove it in the future). I do like the idea of eventually only having one method with transaction = true being the default. Doing bulk operations w/o a transaction feels like an optimization that can be applied in some cases.

What do you think?

(NOTE: another symmetry that follows from treating read operations and mutations symmetrically would be that RecordApi becomes pretty empty with all the fetching logic living inside DeferredOperation.query() implementations 🤷‍♀️ )

@ibilux
Copy link
Contributor Author

ibilux commented Aug 27, 2025

I agree that UX should be the top priority. Personally, I’m okay with all the solutions and suggestions we discussed, but the main goal here is to deliver the best experience for users (I’m not considering myself a user for now to avoid bias 😅).

I think immediate execution should be named execute or run, because query typically implies read-only operations.

And Since we’re already going through major changes, introducing a create just to remove it later feels like another breaking change. If we postpone non-transactional bulk operations, I think bulk is a better name and should be typed to accept only create operations.

Ultimately, I think we need users opinion and feedback on this before finalizing the approach ?

@ignatz
Copy link
Contributor

ignatz commented Aug 28, 2025

Ultimately, I think we need users opinion and feedback on this before finalizing the approach ?

That would be ideal but is sadly hard to come by. I'm very open to pull folks in, any suggestions?

I’m not considering myself a user for now to avoid bias 😅

I think you should. You're taking all this time out of your day to look at this problem and combined with your experience, your opinion matters (a lot) and will always be more informed than some's responding to a survey on the toilet :)

(Just as a reminder, we aren't blocked. We can decide and run as long as it makes sense to us. That said, more eyeball more better when available)

I think immediate execution should be named execute or run, because query typically implies read-only operations.

I see where you're coming from and am happy either way. (In the context of databases, client.query usually returns records and can be mutating, while client.execute is typically a fire-and-forget and therefore tends to be mutating).

And Since we’re already going through major changes, introducing a create just to remove it later feels like another breaking change. If we postpone non-transactional bulk operations, I think bulk is a better name and should be typed to accept only create operations.

Feedback taken. Then let's update the transaction handler to support both execution modes. (Happy to give it a go, just let me know)

@ibilux
Copy link
Contributor Author

ibilux commented Aug 29, 2025

So the API would look something like this:

class DeferredOperation<ResponseType> {
  async query(): Promise<ResponseType>;
}

class DeferredMutation<ResponseType> extends DeferredOperation<ResponseType> {
  toJSON(): Operation;
}

export class CreateOperation<T> extends DeferredMutation<RecordId> {}
export class UpdateOperation<T> extends DeferredMutation<void> {}
export class DeleteOperation extends DeferredMutation<void> {}
export class ReadOperation<T> extends DeferredOperation<T> {}
export class ListOperation<T> extends DeferredOperation<ListResponse<T>> {}

export interface RecordApi<T = Record<string, unknown>> {
  // Write operations
  create(record: T): CreateOperation<T>;
  update(id: RecordId, record: Partial<T>): UpdateOperation<T>;
  delete(id: RecordId): DeleteOperation;

  // Read operations
  read(id: RecordId, opt?: { expand?: string[] }): ReadOperation<T>;
  list(opts?: ListOptions): ListOperation<T>;

  // Subscribe
  subscribe(id: RecordId): Promise<ReadableStream<Event>>;
}

class Client {
  async execute(
    ops: DeferredMutation[],
    options?: { transaction?: boolean },
  ): Promise<RecordId[]>;
}

The only slightly awkward bit is this pattern:

const simple : Simple = await api.read(id).query();

The asymmetry is unavoidable because we can’t defer a subscription anyway (subscribe method). So I think we should embrace the asymmetry and just keep read and list methods immediate since they don’t really participate in transactions (or do you have some future plans for this ?).

I can see a benefit to making read and list operations deferred if we moved the options parameters (like expand, filters, etc.) to the query() method instead of the operation constructor for re-usability. Something like:

const simple : Simple = await api.read(id).query({ expand: ["author"] }); // Options passed to query()

Although this may open the door for another asymmetry.


For the bulk operation on the server side, is the idea simply to iterate through the operations like this?
Or do you think we should aim for something more like run_queries?

#[derive(Clone,Debug,Deserialize,Serialize,ToSchema)]
pub struct TransactionRequest{
  operations : Vec < Operation >,
  transaction : Option < bool >, // Default to true or false?
}

let ids = if request.transaction.unwrap_or(true){
  state.conn().call(move | conn : & mut rusqlite : : Connection |{
    let tx = conn.transaction()?;
    let mut ids : Vec < String > = vec ! [];
    for op in operations{
      if let Some(id)= op(& tx)?{
        ids.push(id);
      }
    }
    tx.commit()?;
    Ok(ids)
  }).await ? 
}else{
   // New non-transactional bulk mode
  let mut ids : Vec < String > = vec ! [];
  for op in operations{
    let result = state.conn().call(move | conn : & mut rusqlite : : Connection |{
      op(conn)
    }).await ?;
    if let Some(id)= result{
      ids.push(id);
    }
  }
  ids
};

@ignatz
Copy link
Contributor

ignatz commented Sep 1, 2025

Hey @ibilux, sorry for the slow reply. Wasn't feeling so hot but now back on my feet.

Client

I love your proposed client-side API - very clean ❤️ . As for your points on asymmetry, I could again be swayed either way. Personally, I don't mind that read/list are deferred too much. To riff a bit more on your proposal, if you moved expand into the .query(), you could even have:

const read : ReadOperation<Simple> = api.read(id);
const simple = await read.query({ expand: ["author"] });

const stream = read.subscribe();

On the bright side, it would remove the asymmetry but it may make subscribe a bit less discoverable. Also none of the other query methods have any arguments. Not sure, which is best.

Spelling it out here, I feel that subscribe is more like list than read however it doesn't support fancy filters (it wouldn't be easy to implement but it would make a lot of sense for it to do). It also doesn't support expand (which maybe it should, but I'm feeling less strongly about). Arguing against myself, I'm not sure folding it into either list or read deferred operation would be great, at least at this point.

To sum up:

  1. read/list deferred, no arguments in query and a separate subscribe.
  2. read/list immediate
  3. arguments on read/list(id).query with separate subscribe

None of them strikes me as "terrible". From a mere symmetry argument, I'm actually leaning (1). Thinking further, maybe subscribe should be deferred as well....
subscribe is fairly minimal right now. I could totally see a world in which subscribe would or should support a more complex lifecycle, e.g. pause, resume, onDisconnect. Especially the latter, it would be nice to provide a mechanism to "catch back up" when the connection is interrupted or the backend is restarted (e.g. for an update).

Do you feel like we're happy enough and at a point where we could jump to implementation? FWIW, I'm very exited how this is shaping up 🙏

Server

For the bulk operation on the server side, is the idea simply to iterate through the operations like this?
Or do you think we should aim for something more like run_queries

Still paging in, where do you see the main difference between your sketch and run_queries?

Signed-off-by: Bilux <i.bilux@gmail.com>
Signed-off-by: Bilux <i.bilux@gmail.com>
Signed-off-by: Bilux <i.bilux@gmail.com>
Signed-off-by: Bilux <i.bilux@gmail.com>
@ibilux
Copy link
Contributor Author

ibilux commented Sep 2, 2025

Do you feel like we're happy enough and at a point where we could jump to implementation? FWIW, I'm very exited how this is shaping up 🙏

I have updated the code for js client library only to see how it go. I noticed from the github action that the examples should be updated too after we settle on the implementation.
The bulk creation should be removed from create_record.rs too I guess ?

Server

Still paging in, where do you see the main difference between your sketch and run_queries?

From may understanding to the code (correct me if I'm wrong) the run_queries function has a file handling that ensures files are written to object storage before the database transaction commits, and if the transaction fails, the file manager automatically cleans up any files that were written ?

Shouldn't a similar approach be implemented for transaction API ?

@ignatz
Copy link
Contributor

ignatz commented Sep 2, 2025

I have updated the code for js client library only to see how it go.

You're a machine 👏

should be updated too after we settle on the implementation.

After writing it out, how do you feel about it?

I would love to hear your unbiased feedback. Yet, I'll share my thoughts to minimize round-trips... I love the implementation. Everything is extremely symmetric, composable and mockable. Yet, seeing it spelled out it's clearly more cumbersome then before when you're not using transactions. I'm wondering if we could just leave the implementation as is and add short-hands, e.g. create: (props) => await this.createOp(props).query(). We'd have two sets of APIs, perfectly symmetric with a single implementation (it would also end up being backwards compatible). WDYT? (I'm sorry for going back and forth on this 🙏 - I really do love the current implementation)

From may understanding to the code (correct me if I'm wrong) the run_queries function has a file handling that ensures files are written to object storage before the database transaction commits, and if the transaction fails, the file manager automatically cleans up any files that were written ?

Shouldn't a similar approach be implemented for transaction API ?

Yikes, if that's not already the case, I would consider it a bug. You're absolutely right that also for transaction we need to cleanup overriden files or files after a transaction fails. I'll take a look.

expand?: string[];
},
): Promise<T>;
export interface Operation {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not the most proficient TS programmer, is this something better modeled as is or as a union CreateOperation<unknown>| UpdateOperation<unknown> | DeleteOperation<unknown>?

Copy link
Contributor Author

@ibilux ibilux Sep 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use Types:

interface CreateOp {
  Create: { api_name: string; value: Record<string, unknown> };
}
interface UpdateOp {
  Update: { api_name: string; record_id: string | number; value: Record<string, unknown> };
}
interface DeleteOp {
  Delete: { api_name: string; record_id: string | number };
}

type Operation = CreateOp | UpdateOp | DeleteOp;

export class CreateOperation<T>  implements DeferredMutation<string | number>
{
  toJSON(): CreateOp { ... }
}

Or use generic helper:

type OperationKind = "Create" | "Update" | "Delete";

type OperationMap = {
  Create: { api_name: string; value: Record<string, unknown> };
  Update: { api_name: string; record_id: string | number; value: Record<string, unknown> };
  Delete: { api_name: string; record_id: string | number };
};

type Operation<K extends OperationKind> = { [P in K]: OperationMap[P] };

export class CreateOperation<T>  implements DeferredMutation<string | number>
{
  toJSON(): Operation<"Create"> { ... }
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naively, I like some notion of union since it expresses AnyOf<...> rather than AnyOfOrNone<...>. I couldn't tell you if that's idiomatic or which implementation is best. I trust you.

Wouldn't my original proposal work as a minimal change with the existing abstractions?

const ops : (CreateOperation<unknown> | UpdateOperation<unknown> | DeleteOperation<unknown>)[] = [
  api.createOp(...),
  // ...
];
const operations: Operation[] = ops.map((o) => o.toJSON());

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re right, the current Operation interface allows AnyOfOrNone, which means you could accidentally construct an invalid shape (none or multiple at once). Switching to a union (CreateOp | UpdateOp | DeleteOp) enforces exactly one and avoids that issue.

@ibilux
Copy link
Contributor Author

ibilux commented Sep 2, 2025

After writing it out, how do you feel about it?

the await client.create({...}).query() felt a bit weird at first, but i can see the benefit is some cases where defining the op and then querying it multiple times await op.query().

I would love to hear your unbiased feedback. Yet, I'll share my thoughts to minimize round-trips... I love the implementation. Everything is extremely symmetric, composable and mockable. Yet, seeing it spelled out it's clearly more cumbersome then before when you're not using transactions. I'm wondering if we could just leave the implementation as is and add short-hands, e.g. create: (props) => await this.createOp(props).query(). We'd have two sets of APIs, perfectly symmetric with a single implementation (it would also end up being backwards compatible). WDYT? (I'm sorry for going back and forth on this 🙏 - I really do love the current implementation)

You mean use two APIs records and transaction but the records API calls the Operation immediately internally ?

@ignatz
Copy link
Contributor

ignatz commented Sep 2, 2025

the await client.create({...}).query() felt a bit weird at first, but i can see the benefit is some cases where defining the op and then querying it multiple times await op.query().

Agreed - like that too. I'm not suggesting to change that

You mean use two APIs records and transaction but the records API calls the Operation immediately internally ?

With symmetry intact, from a mere convenience perspective and looking at the new test-cases I wouldn't mind them co-existing on RecordApi, i.e.

const result = await api.read(...);

const deferredRead = api.readOp(...);
setInterval(() => deferredRead.query().then(update), 1000);

You probably have better names, e.g. deferredRead, readQuery, ...

WDYT?

@ibilux
Copy link
Contributor Author

ibilux commented Sep 3, 2025

With symmetry intact, from a mere convenience perspective and looking at the new test-cases I wouldn't mind them co-existing on RecordApi, i.e.

const result = await api.read(...);

const deferredRead = api.readOp(...);
setInterval(() => deferredRead.query().then(update), 1000);

You probably have better names, e.g. deferredRead, readQuery, ...

WDYT?

This does introduce some API surface duplication, which could cause the confusion we talked about earlier.

On the other hand, it maintains backward compatibility and gives flexibility for different use cases.

I think this is the best course of action for a smooth migration. We can always revisit and consolidate later once we see how developers actually use the two flavors in practice.

@ibilux
Copy link
Contributor Author

ibilux commented Sep 4, 2025

Hello @ignatz, I have updated the PR.

Copy link
Contributor

@ignatz ignatz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks phenomenal - only minor nits (the accidental looking changes to .pre-commit-config.yaml and config.textproto are probably just stale?).

I would love to merge this. Thank you so much, both for the work and bearing with me 🙏

record_id: string | number;
};
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:nit: In-line into DeferredMutation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, inlining makes it cleaner.

count?: boolean;
expand?: string[];
}): Promise<ListResponse<T>>;
export interface CreateOp {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Please leave as is, in the future we could probably also just use ts_rs code-gen to generate these types).

We could probably reduce the public API surface, if we simply removed DeferredMutation (or at least made it private). Note that

async execute(
    operations: (CreateOperation | UpdateOperation | DeleteOperation)[],
    transaction: boolean = true,
  ): Promise<(string | number)[]> {

doesn't need DeferredMuration and you can safely call toJSON simply due to all union members having it. (structural typing for the win, other languages won't treat us so nicely)

We could then make these internal type definitions here private as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was planning to ask you after we settled on the implementation about which classes should remain exported and which should not.
I’ve removed the export. But, I think we should keep DeferredOperation and DeferredMutation only as a reference for other clients implementation.

private readonly apiName: string,
private readonly record: Partial<T>,
) {}
async query(): Promise<string> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this match the T in DeferredMutation<T>?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I meant to fix that, but, somehow got distracted. My plan was to introduce a dedicated RecordId type:

export type RecordId = string | number;

expand?: string[];
}): ListOperation<T>;

readOp(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:super nitty: just from a discoverability point of view, i'd group and Op next to each other.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You’re right.

private readonly client: Client,
private readonly name: string,
) {
this._path = `${recordApiBasePath}/${this.name}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is needed anymore 🎉

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was used in subscribe method, but it’s better to remove and replace it.

@ibilux
Copy link
Contributor Author

ibilux commented Sep 5, 2025

This looks phenomenal - only minor nits (the accidental looking changes to .pre-commit-config.yaml and config.textproto are probably just stale?).

I would love to merge this. Thank you so much, both for the work and bearing with me 🙏

Thanks @ignatz! I’m really glad I could help, and I’ve enjoyed our conversations along the way.
I’ve addressed all your points.

Regarding the next steps, would it make sense to close this PR and open separate ones to keep the git history cleaner? I’m thinking:

  1. Adding the bulk operation to transaction.rs
  2. Enabling the transactions API in client/testfixture/config.textproto (unless you have a preferred approach)
  3. Updating the JS client

@ignatz
Copy link
Contributor

ignatz commented Sep 5, 2025

I’m really glad I could help

Much much much appreciated 🙏

I’ve enjoyed our conversations along the way

pfew 😅

Regarding the next steps, would it make sense to close this PR and open separate ones to keep the git history cleaner? I’m thinking:

Adding the bulk operation to transaction.rs
Enabling the transactions API in client/testfixture/config.textproto (unless you have a preferred approach)
Updating the JS client

I leave this 100% up to you. Ultimately it's just cosmetics and doesn't take away from the quality of your work. Just let me know.

(I won't be available this weekend, only bringing this up in case you're keen to get this in before. Want to be mindful of your time)

@ibilux
Copy link
Contributor Author

ibilux commented Sep 6, 2025

I leave this 100% up to you. Ultimately it's just cosmetics and doesn't take away from the quality of your work. Just let me know.

(I won't be available this weekend, only bringing this up in case you're keen to get this in before. Want to be mindful of your time)

Got it, thanks @ignatz 🙏.
Since splitting is just cosmetics, I’ll keep this PR as-is and we can merge it directly. I really appreciate your time and feedback — no rush, I’m happy to wait until you’re back.

My vacation just ended and I’m back to work, so I’ll follow up with separate PRs for the other clients when I can.

@ignatz
Copy link
Contributor

ignatz commented Sep 8, 2025

Since splitting is just cosmetics, I’ll keep this PR as-is and we can merge it directly. I really appreciate your time and feedback — no rush, I’m happy to wait until you’re back.

👍

My vacation just ended and I’m back to work, so I’ll follow up with separate PRs for the other clients when I can.

🙏 thanks for committing so much effort... really really appreciated

I did a few minor tweaks, mostly just adding createBulk back for now with a note for future deprecation. I though with the latest compatibility developments it would be nice to offer a compatible transition period (I also wasn't sure if there would be an issue with the file-handling, since I still haven't looked into whether the transaction handler handles files correctly :hide:).

116ce87

I've squashed and merged your change into upstream dev

698e38d

I really love how it turned out 🙏

@ignatz
Copy link
Contributor

ignatz commented Sep 8, 2025

Quick update: I published a new npm package for the client and cutting a new server release 🙏

@aMarCruz
Copy link

aMarCruz commented Dec 3, 2025

In my humble and belated opinion as a TrailBase novice...
This proposal is beautiful in its simplicity, easy to understand at first glance, and very much in line with how SQLite (and SQL in general) handles transactions:

const tx = client.transaction();  
tx.records("users").create(record);  
tx.records("posts").update(id, data);  
await tx.commit(); // or tx.rollback()

But you are experts at this, and you have my admiration and gratitude for the excellent work you offer us for free.

@ignatz
Copy link
Contributor

ignatz commented Dec 3, 2025

In my humble and belated opinion as a TrailBase novice... This proposal is beautiful in its simplicity, easy to understand at first glance, and very much in line with how SQLite (and SQL in general) handles transactions:

const tx = client.transaction();  
tx.records("users").create(record);  
tx.records("posts").update(id, data);  
await tx.commit(); // or tx.rollback()

@aMarCruz Welcome! - and thanks for the input. I agree that it may feel very natural. At the same time, what we ultimately settled on is a bit closer to what actually happens under the hood allowing for a layered approach. Specifically, it would be easy today for a first or third-party to implement an abstraction like:

const tx = transaction(client);  
tx.records("users").create(record);  
tx.records("posts").update(id, data);  
await tx.commit();

Maybe fun fact: this is very similar to the "raw" SQLite API you get when you run JS/TS in a WASM guest: https://github.com/trailbaseio/trailbase/blob/main/client/testfixture/guests/typescript/src/index.ts#L59-L72.

I'm not sure how misleading such an API would be on the network client. In practice, it's really more of a batch API for mutations. It would be very problematic for clients to lock the remote DB and then iterate on it. So, begin() doesn't lock the DB, there's no conditionals and rollback() would be a noop. Does that make sense?

@aMarCruz
Copy link

aMarCruz commented Dec 3, 2025

@ignatz thanks

I'm not sure how misleading such an API would be on the network client. In practice, it's really more of a batch API for mutations. It would be very problematic for clients to lock the remote DB and then iterate on it. So, begin() doesn't lock the DB, there's no conditionals and rollback() would be a noop. Does that make sense?

Yes, I thought of it as a batch of mutations collected by tx without blocking mutations on the involved tables.
with tx.commit() executing "BEGIN TRANSACTION" and so on, or with tx.rollback() giving the opportunity to clean up transient resources and invalidating tx.
And you're right, it is very different from the sequential processing of a typical SQL transaction.

An alternative I like is how Firestore does it, which I have some experience with, and I think PocketBase does something similar:

import { runTransaction } from "firebase/firestore";

try { 
  await runTransaction(db, async (transaction) => { 
    const sfDoc = await transaction.get(sfDocRef); 
    if (!sfDoc.exists()) { 
      throw "Document does not exist!"; 
    } 

    const newPopulation = sfDoc.data().population + 1; 
    transaction.update(sfDocRef, { population: newPopulation }); 
  }); 
  console.log("Transaction successfully committed!");
} catch (e) { 
  console.log("Transaction failed: ", e);
}

Anyway, I'm sure this API will be as good as the rest of TrailBase. I'll use it with TanStack and SolidJS to develop an multiuser SPA with client-first collections (multi-database support would be great for this).

Thank you again!

@ignatz
Copy link
Contributor

ignatz commented Dec 3, 2025

Anyway, I'm sure this API will be as good as the rest of TrailBase. I'll use it with TanStack and SolidJS to develop an multiuser SPA with client-first collections (multi-database support would be great for this).

Curious question, since I'm currently looking into multi-DB. Are you thinking of a small, more-or-less fixed number of DBs or a 1:1 DB to user mapping (~multi-tenancy)? The former is pretty close, the latter will need a bit more thought.

@aMarCruz
Copy link

aMarCruz commented Dec 3, 2025

Curious question, since I'm currently looking into multi-DB. Are you thinking of a small, more-or-less fixed number of DBs or a 1:1 DB to user mapping (~multi-tenancy)? The former is pretty close, the latter will need a bit more thought.

Yes, a 1:1 DB per customer. Each customer can have zero or more users accessing a synchronized copy of the same BD.
Migrations will undoubtedly be more complex, but I think there will be improvements in simplicity and overall performance. Something similar to what Electric's Shapes offer, but with SQLite DBs.

@ignatz
Copy link
Contributor

ignatz commented Dec 4, 2025

Yes, a 1:1 DB per customer. Each customer can have zero or more users accessing a synchronized copy of the same BD. Migrations will undoubtedly be more complex, but I think there will be improvements in simplicity and overall performance. Something similar to what Electric's Shapes offer, but with SQLite DBs.

I'm no expert on Electric's offering, maybe you can help me out, it looks like there's a single central DB with some policy engine that decides which changes get streamed out to which on-device databases.

What I first understood and am working on is: multiple SQLite DBs on the server. From multi-tenancy I would expect data silos, i.e. users can only ever access (read & write) data in their silos (e.g. 1:1 mapping for users or something more complex like members of a customer, as you're asking).

FWIW, something like Electric's policy-based streaming one can already model on the existing building blocks. Specifically, you can have a group membership table to associate records in another table with a group. Then you can setup access rules that only group members can access said records, in which case any user subscribing for changes will only see records they have access to. At least on Web you can use TanStack DB as a client-side queryable, synced DB.

My intuition is that one always wants to have more fine-grained access on streaming then just, stream this DB. Even in a multi-tenancy world, you might only want certain updates.

Note that TB subscriptions also support client-side filters to further narrow the changes, this is on-top of the server-side access control. For example, you could subscribe to a stream of geo coordinates associated with your customer, which are within a specific bounding box.

Maybe you could talk a bit more to what you're trying to do to then work our way backwards and how to best model it.

@ibilux
Copy link
Contributor Author

ibilux commented Dec 4, 2025

Excuse my intrusion, but I’ve been looking forward to multi-database support since I saw the roadmap. My understanding was that ‘multi-database’ in TrailBase meant multiple SQLite DBs, each with its own schema. And I was curious how the separation of these DBs would work in terms of selection, migration, storage, ... etc. I think multi-tenancy could already be handled on top of this using TrailBase ACL and access rules ?

@ignatz
Copy link
Contributor

ignatz commented Dec 4, 2025

Excuse my intrusion, but I’ve been looking forward to multi-database support since I saw the roadmap.

Arguably we're intruding on your PR

My understanding was that ‘multi-database’ in TrailBase meant multiple SQLite DBs, each with its own schema. And I was curious how the separation of these DBs would work in terms of selection, migration, storage, ... etc.

Currently, I'm aiming for the minimal viable product to get things going and iron out the kinks. Specifically, allowing to register additional databases, which map to <traildepot>/data/<name>.db and <traildepot>/migrations/<name>/*.sql, which means you can have independent schemas. Record APIs can then reference fully-qualified tables, e.g. <name>.mytable.

I'm currently also attaching all DBs to the same connection, which has the benefit that cross-DB VIEWs will simply work, however at the downside of only supporting up to 124 DBs. To allow more DBs, we'd have to manage connections more actively, e.g. manage different connections or dynamically attach/detach DBs.

EDIT: spelling things out, I'm starting to think that cross-DB VIEWs is maybe a feature that can wait in favor of more independent DBs 🤷‍♀️

I think multi-tenancy could already be handled on top of this using TrailBase ACL and access rules ?

Above limitations aside, it depends what you expect it to do. If you have a 1000 tenants, every API would become 1000 APIs if they needed to be backed by independent DBs. If you want the same schema for each tenant, you'd also need some bootstrapping and maybe a few more lifecycle hooks, e.g. to clean up deleted tenants.

Would love to hear your thoughts 🙏

@aMarCruz
Copy link

aMarCruz commented Dec 5, 2025

I'm no expert on Electric's offering, maybe you can help me out, it looks like there's a single central DB with some policy engine that decides which changes get streamed out to which on-device databases.

Yes, it is a single DB.
[Shapes are defined by](Shapes are defined by:

a table, such as items
an optional where clause to filter which rows are included in the shape
an optional columns clause to select which columns are included
A shape contains all of the rows in the table that match the where clause, if provided. If a columns clause is provided, the synced rows will only contain those selected columns.

I'm not an expert in Electric, but the concept is simple: they're SQL queries that expose a set of records to the client as if it were the entire table. It's what some RDBMSs called "Views", but mutables.

What I first understood and am working on is: multiple SQLite DBs on the server. From multi-tenancy I would expect data silos, i.e. users can only ever access (read & write) data in their silos (e.g. 1:1 mapping for users or something more complex like members of a customer, as you're asking).

Yes, 1 SQLite DB for customer (the root user for this db).
This is similar to Electric's Shapes, but without putting all your eggs in one only basket, with the benefits that this entails :-)

FWIW, something like Electric's policy-based streaming one can already model on the existing building blocks. Specifically, you can have a group membership table to associate records in another table with a group. Then you can setup access rules that only group members can access said records, in which case any user subscribing for changes will only see records they have access to. At least on Web you can use TanStack DB as a client-side queryable, synced DB.

My intuition is that one always wants to have more fine-grained access on streaming then just, stream this DB. Even in a multi-tenancy world, you might only want certain updates.

Note that TB subscriptions also support client-side filters to further narrow the changes, this is on-top of the server-side access control. For example, you could subscribe to a stream of geo coordinates associated with your customer, which are within a specific bounding box.

Maybe you could talk a bit more to what you're trying to do to then work our way backwards and how to best model it.

My app will be like any other administrative App, think of one for restaurants, for example...

A client registers their restaurant in the App. At that moment, a new DB is created on the server. This DB will act as a backup and synchronization point for all restaurant users (cashiers, POS systems, accountants, etc.). The app needs to know the DB URL and whether the subscription is active. The other restaurants are not accessible to the App. This simplifies everything and minimizes database locks.

On the server, a master DB (not directly accessible from the app) will contain records of restaurant owners, their account type, subscribed modules, balance, etc. The database filename corresponds to the owner's ID and maintains a read-only record with their data in a table.

On the server, it's only necessary to create the new DB when a restaurant registers and grant access to its owner. The rest of the users and their roles in that restaurant are controlled by the App.

Of course, this idea isn't new. Over 20 years ago, I created a similar app in Visual FoxPro 6 with local DBFs synchronized with an NT4 server, and it worked very well. Implementing it with MS SQL would have taken us twice as much time and money.

Sorry for rambling on. I hope I answered correctly; English isn't my first language, and I barely understand it.

@aMarCruz
Copy link

aMarCruz commented Dec 5, 2025

My understanding was that ‘multi-database’ in TrailBase meant multiple SQLite DBs, each with its own schema. And I was curious how the separation of these DBs would work in terms of selection, migration, storage, ... etc.

Currently, I'm aiming for the minimal viable product to get things going and iron out the kinks. Specifically, allowing to register additional databases, which map to <traildepot>/data/<name>.db and <traildepot>/migrations/<name>/*.sql, which means you can have independent schemas. Record APIs can then reference fully-qualified tables, e.g. <name>.mytable.

I'm currently also attaching all DBs to the same connection, which has the benefit that cross-DB VIEWs will simply work, however at the downside of only supporting up to 124 DBs. To allow more DBs, we'd have to manage connections more actively, e.g. manage different connections or dynamically attach/detach DBs.

This is one of the problems to be solved by the implementation; I can imagine a successful app requiring 1,000 DBs or more, although not all connected at the same time.

EDIT: spelling things out, I'm starting to think that cross-DB VIEWs is maybe a feature that can wait in favor of more independent DBs 🤷‍♀️

I think multi-tenancy could already be handled on top of this using TrailBase ACL and access rules ?

Above limitations aside, it depends what you expect it to do. If you have a 1000 tenants, every API would become 1000 APIs if they needed to be backed by independent DBs. If you want the same schema for each tenant, you'd also need some bootstrapping and maybe a few more lifecycle hooks, e.g. to clean up deleted tenants.

One App, one schema.
Tenant cleaning is done with a small routine and cron job.
Migration is another story.

@aMarCruz
Copy link

aMarCruz commented Dec 5, 2025

Excuse my intrusion, but I’ve been looking forward to multi-database support since I saw the roadmap. My understanding was that ‘multi-database’ in TrailBase meant multiple SQLite DBs, each with its own schema. And I was curious how the separation of these DBs would work in terms of selection, migration, storage, ... etc. I think multi-tenancy could already be handled on top of this using TrailBase ACL and access rules ?

Multiple databases with the same schema and migrations.
But it's a specific requirement; I think it's suitable for SQLite, although perhaps not common.

@ignatz
Copy link
Contributor

ignatz commented Dec 5, 2025

Sorry for rambling on. I hope I answered correctly; English isn't my first language, and I barely understand it.

Same boat. Your english is great. Your insights and 20 year war stories are greatly appreciated.

Everything you say makes a lot of sense. I'm mostly just trying to keep "multi-tenancy" and "syncing" as two separate features, with possibly synergistic effects. Specifically, Electric with shapes seems to market itself mostly on read-only syncing from a single master Postgres instance. (I did find some reference to support for multiple but independent PG DBs in code: electric-sql/electric@3775f1c - but not when skimming the docs).

Syncing

On the server, it's only necessary to create the new DB when a restaurant registers and grant access to its owner. The rest of the users and their roles in that restaurant are controlled by the App.

Skimming Electric's docs, this is the one part where I'm not sure. Please correct me if I'm wrong. My understanding is that the docs use "client" and "app" pretty interchangeably. So, a sync-client might exist either in one of your backends or may be directly running on your users' devices as part of an app. Either way, the new DB is created on the client side and not within the Electric's stack (PG + Sync). This client-side DB may then be backed by SQLite, PGLite, TanStack DB, ... . In other words, whether that per-client (which may correspond to a "tenant") DB is "on the server" (as you say) or somewhere else, depends on your setup and is not Electric's responsibility.... then again, I may be in the weeds.

If this understanding is roughly correct, many similar building blocks are already in place. Specifically, you can sync your table contents into TanStack DB, whether it's running on the server or in your web app.

(Btw. I'm sure Electric is great, I'm not trying to trash talk, I'm mostly trying to understand and then think about what aspects or features similar to or different from prior art may make sense in TrailBase).

I'm wondering if maybe you're thinking ahead, e.g. unlike Electric, breaking up per-tenant data into separate DBs could allow for a more 1:1 sync between a server-side DB shard and a client? Maybe even enable write-path syncing?

Multi-Tenancy

Multiple databases with the same schema and migrations.

This is what I would understand as multi-tenancy, too. IIUC, Electric doesn't help with this either?

Relatedly, you could also have a multi-environment setup, e.g. test, QA, and prod data physically separated, as opposed to data for different tenants physically separated.

Either way, either would be cool to support. As you point out there's some extra work for lifecycle management.

The first step right now for me is to support multiple independent databases to allow different data to be kept in different physical locations and overcome some limitations (e.g. locking). This wouldn't yet allow to auto-magically route different tenants to different DBs but you could manually break by topic or establish a manual routing based on policy requirements (e.g. EMEA vs north-america 🤷‍♀️).

I can imagine a successful app requiring 1,000 DBs or more, although not all connected at the same time.

Absolutely and will be an absolute requirement for true multi-tenancy... baby steps :)

@ibilux
Copy link
Contributor Author

ibilux commented Dec 5, 2025

Currently, I'm aiming for the minimal viable product to get things going and iron out the kinks. Specifically, allowing to register additional databases, which map to <traildepot>/data/<name>.db and <traildepot>/migrations/<name>/*.sql, which means you can have independent schemas. Record APIs can then reference fully-qualified tables, e.g. <name>.mytable.

This matches exactly what I originally understood by multi-database support — independent SQLite DBs, each with their own schema and folders, and callable via fully-qualified table names.

I'm currently also attaching all DBs to the same connection, which has the benefit that cross-DB VIEWs will simply work, however at the downside of only supporting up to 124 DBs. To allow more DBs, we'd have to manage connections more actively, e.g. manage different connections or dynamically attach/detach DBs.

I agree that cross-DB VIEWs are nice to have, but supporting more than 124 DBs is more valuable than automatic cross-DB VIEWs.
Maybe a hybrid model could work later: only DBs that explicitly opt-in a shared connection get attached for VIEWs, while the rest use separate connections and remain fully isolated or managed per-connection.

EDIT: spelling things out, I'm starting to think that cross-DB VIEWs is maybe a feature that can wait in favor of more independent DBs 🤷‍♀️

Yes, cross-DB VIEWs definitely feel like something that can wait, especially if they complicate scaling past the 124-DB attachment limit (Since it's an SQLite limit, not TrailBase).

Above limitations aside, it depends what you expect it to do. If you have a 1000 tenants, every API would become 1000 APIs if they needed to be backed by independent DBs. If you want the same schema for each tenant, you'd also need some bootstrapping and maybe a few more lifecycle hooks, e.g. to clean up deleted tenants.

I think the key question here is:
What do TrailBase users actually intend to build, and in which real-world scenarios does multi-DB support help them the most?
Understanding that will make it much easier to decide whether multi-DB should lean toward modularity, multi-tenant, or something more flexible.

(And by the way, I genuinely appreciate dense replies full of insight and new information! 🙌)

@ignatz
Copy link
Contributor

ignatz commented Dec 5, 2025

What do TrailBase users actually intend to build, and in which real-world scenarios does multi-DB support help them the most?

Agreed - certainly whenever possible. Meanwhile trying to fill the gaps with looking at prior art. That's why it's so fascinating to see what Electric an others have done.

This matches exactly what I originally understood by multi-database support — independent SQLite DBs, each with their own schema and folders, and callable via fully-qualified table names.

Taking this as customer satisfaction 😅

I agree that cross-DB VIEWs are nice to have, but supporting more than 124 DBs is more valuable than automatic cross-DB VIEWs.
Maybe a hybrid model could work later: only DBs that explicitly opt-in a shared connection get attached for VIEWs, while the rest use separate connections and remain fully isolated or managed per-connection.

Something like that. Besides views, there's also a question of how to deal with it in the SQL editor. Maybe users should just attach DBs manually, e.g. ATTACH DATABASE get_db_path("other") AS other; 🤷‍♀️ . Maybe we can provide some UI that lets users select a subset of pre-attached DBs 🤷‍♀️.

Despite the extra challenges, I agree that an MVP should probably have more sophisticated connection management to avoid later surprises.

@ibilux
Copy link
Contributor Author

ibilux commented Dec 6, 2025

Taking this as customer satisfaction 😅

Yes, customer satisfaction achieved 😂. I was actually planning to start a discussion about multi-DB last month, but I didn’t want to rush things.

there's also a question of how to deal with it in the SQL editor. Maybe users should just attach DBs manually, e.g. ATTACH DATABASE get_db_path("other") AS other; 🤷‍♀️ . Maybe we can provide some UI that lets users select a subset of pre-attached DBs 🤷‍♀️.

Both approaches are viable, and i think neither is a blocker for moving forward.

Meanwhile trying to fill the gaps with looking at prior art. That's why it's so fascinating to see what Electric an others have done.
Despite the extra challenges, I agree that an MVP should probably have more sophisticated connection management to avoid later surprises.

Totally agree, given the long-term implications, having smarter connection management in the MVP feels like the best direction. And yes, referencing how previous multi-DB architectures handled this in real-world systems will definitely help guide the design.

On a side note (just brainstorming here, I realize this might introduce breaking changes), what if the API structure itself included the database name? Something like:

/api/records/v2/<db_name>/<api_name>

This would allow each database to behave like TrailBase does now, but with its own “silo” of attached DBs under the hood. The directory structure might then evolve into something like:

<traildepot>/data/<db_name>/main.db
<traildepot>/data/<db_name>/migrations/
<traildepot>/data/<db_name>/…
<traildepot>/data/<db_name>/<attached_db_name1>.db
<traildepot>/data/<db_name>/<attached_db_name2>.db
<traildepot>/data/<db_name>/<attached_db_name…>.db

@aMarCruz
Copy link

aMarCruz commented Dec 7, 2025

Sorry for rambling on. I hope I answered correctly; English isn't my first language, and I barely understand it.

Same boat. Your english is great. Your insights and 20 year war stories are greatly appreciated.

Everything you say makes a lot of sense. I'm mostly just trying to keep "multi-tenancy" and "syncing" as two separate features, with possibly synergistic effects. Specifically, Electric with shapes seems to market itself mostly on read-only syncing from a single master Postgres instance. (I did find some reference to support for multiple but independent PG DBs in code: electric-sql/electric@3775f1c - but not when skimming the docs).

You're right, I started the implementation two days ago, and indeed, Shapes are not mutable. Although their documentation includes references to Partitions, which are, they recommend maintaining a local table with the changes until they can be written to the primary DB.

Syncing

On the server, it's only necessary to create the new DB when a restaurant registers and grant access to its owner. The rest of the users and their roles in that restaurant are controlled by the App.

Skimming Electric's docs, this is the one part where I'm not sure. Please correct me if I'm wrong. My understanding is that the docs use "client" and "app" pretty interchangeably. So, a sync-client might exist either in one of your backends or may be directly running on your users' devices as part of an app. Either way, the new DB is created on the client side and not within the Electric's stack (PG + Sync). This client-side DB may then be backed by SQLite, PGLite, TanStack DB, ... . In other words, whether that per-client (which may correspond to a "tenant") DB is "on the server" (as you say) or somewhere else, depends on your setup and is not Electric's responsibility.... then again, I may be in the weeds.

Nowadays, there's a lot of flexibility in all of this, but for client-first solutions, the database (or part of it if it's multi-tenant) is replicated on the client side.
Who handles the replication? It depends on the tool. With SQLite Sync for example, you can't choose; with Electric, you can, although they recommend PGLite you could opt for something lighter for a PWA.

If this understanding is roughly correct, many similar building blocks are already in place. Specifically, you can sync your table contents into TanStack DB, whether it's running on the server or in your web app.

Right.

(Btw. I'm sure Electric is great, I'm not trying to trash talk, I'm mostly trying to understand and then think about what aspects or features similar to or different from prior art may make sense in TrailBase).

I'm wondering if maybe you're thinking ahead, e.g. unlike Electric, breaking up per-tenant data into separate DBs could allow for a more 1:1 sync between a server-side DB shard and a client? Maybe even enable write-path syncing?

Well, after years of not working on the back, I see that RDBMSs haven't changed that much. They were designed to be monolithic, and due to their high demand on operational resources, I think for a single schema, it's better to use a multi-tenant, single-DB approach.

(Following the example of a commercial app for restaurants, by "tenants" I'll refer to the subscribers of that app.)

With SQLite is different, and I believe using 1 DB per tenant is appropriate due to its lightweight nature and lack of embedded security.

Each approach has its advantages/disadvantages; the best one will be determined by the app type, the target environment, and our preferences.

Multi-Tenancy

Multiple databases with the same schema and migrations.

This is what I would understand as multi-tenancy, too. IIUC, Electric doesn't help with this either?

Yes, it's multi-tenant, although I think of that type as single-tenant DBs...

image

Electric doesn't help, but it can be solved with Shapes or Partitions in a multi-tenant DB.

Relatedly, you could also have a multi-environment setup, e.g. test, QA, and prod data physically separated, as opposed to data for different tenants physically separated.

In this, SQLite shines.

Either way, either would be cool to support. As you point out there's some extra work for lifecycle management.

👍🏼

The first step right now for me is to support multiple independent databases to allow different data to be kept in different physical locations and overcome some limitations (e.g. locking). This wouldn't yet allow to auto-magically route different tenants to different DBs but you could manually break by topic or establish a manual routing based on policy requirements (e.g. EMEA vs north-america 🤷‍♀️).

I can imagine a successful app requiring 1,000 DBs or more, although not all connected at the same time.

Absolutely and will be an absolute requirement for true multi-tenancy... baby steps :)

Excellent!

Regarding the difference between client and app...

Client and application are not the same, nor are users and terminals, but depending on the context, they can be interchangeable.

In a client-first SPA, a terminal, or more accurately, the software installed on a device, is the app and acts as the client. This app has a local DB that can serve any number and type of users, although typically only one is active at any given time. Synchronization with the primary DB ("on the server") is delegated to other modules that handle data transport and transformation in both directions. Another module can manage persistence, which in a PWA is usually done with IndexedDB or localStorage, and in a desktop app with SQLite. Yet another module can be used for querying and modifying the local DB.

An SPA with this level of abstraction provides almost the same advantages as working in single-user mode, with local data in a single format, although in reality, the data, as well as the modifications, can come from the primary database and/or multiple internal and external sources, all of which are stored in a store to whose state changes the UI reacts.

A lightweight toolchain like Electric + TanStack DB + TanStack Query + SolidJS fits into this model, and TrailBase, with support for multi-DB and transactions, would be an even better fit, achieving a simplicity and efficiency that's hard to match.

Problems to solve in this model, mostly in the client:

  • Security, which should be at the row level according to the privileges of the logged-in user.
  • Conflict resolution, where CRDT or AI can be very useful.
  • Data/Driver size, since the local DB is expected to adapt to machines with limited storage.

This is why SQLite with a decoupled persistence module, fits naturally into this architecture, especially in projects like the one I'm currently working on, which is a commercial app for small teams.

I am starting implementations with 2 toolchains and in the coming weeks, as I progress, I will have concrete experiences with this architecture.

Un saludo desde México.

@ignatz
Copy link
Contributor

ignatz commented Dec 8, 2025

there's also a question of how to deal with it in the SQL editor. Maybe users should just attach DBs manually, e.g. ATTACH DATABASE get_db_path("other") AS other; 🤷‍♀️ . Maybe we can provide some UI that lets users select a subset of pre-attached DBs 🤷‍♀️.

Both approaches are viable, and i think neither is a blocker for moving forward.

I think a solution for the SQL editor is blocking for an MVP. Not being able to interact with db.table you're shown in the explorer would be very surprising.

This would allow each database to behave like TrailBase does now, but with its own “silo” of attached DBs under the hood. The directory structure might then evolve into something like:

/data/<db_name>/main.db
/data/<db_name>/migrations/
/data/<db_name>/…
/data/<db_name>/<attached_db_name1>.db
/data/<db_name>/<attached_db_name2>.db
/data/<db_name>/<attached_db_name…>.db

I'm not sure why there would be multiple *.db files under /<db_name>/. Generally, I agree that something like this would look nice. However, I worry that it may be misleading. It's not spelled out explicitly anywhere but the setup is trying to split config from data. A toothbrush test would be: should it be checked into VCS or not? Specifically, you'd check in migrations but not the *.db. In other words, I'd think of schemas a configuration and in a multi-tenancy world schemas may apply to multiple DBs.

@ignatz
Copy link
Contributor

ignatz commented Dec 8, 2025

You're right, I started the implementation two days ago, and indeed, Shapes are not mutable. Although their documentation includes references to Partitions, which are, they recommend maintaining a local table with the changes until they can be written to the primary DB.

I'm not sure how PG partitions help with writes. IIUC, writes always have to go to the primary server. They may be opportunistically applied on the client but would need to be rolled back if rejected by the primary. BTW that's what TanStack DB does for you.

Nowadays, there's a lot of flexibility in all of this, but for client-first solutions, the database (or part of it if it's multi-tenant) is replicated on the client side.

Got ya, the parenthesis carry a lot of weight. That's the synergy between sync and partitioning, you're hoping for.

Who handles the replication? It depends on the tool. With SQLite Sync for example, you can't choose; with Electric, you can, although they recommend PGLite you could opt for something lighter for a PWA.

Right. For better or worse, TB is also loosely coppled, i.e. it's not prescriptive on the client-side and you can use TanStack DB or build something else on top of the filtered SSE change streaming (~shapes).

Electric doesn't help, but it can be solved with Shapes or Partitions in a multi-tenant DB.

I see now, "PG partitions" aren't per se helping with syncing (and writes) but may help to mitigate some concerns like physical data isolation (and thus shared access bottlenecks) 👍

Client and application are not the same, nor are users and terminals, but depending on the context, they can be interchangeable.

I think we're in an agreement here.

A lightweight toolchain like Electric + TanStack DB + TanStack Query + SolidJS fits into this model, and TrailBase, with support for multi-DB and transactions, would be an even better fit, achieving a simplicity and efficiency that's hard to match.

Agreed. It's maybe worth pointing out that from the view of the client-side app, multi-tenancy or not is an implementation detail. We're arguing architectural simplicity, e.g. establishing 1:1 mappings wherever possible, which is absolutely important but not the client's concern.

Problems to solve in this model, mostly in the client:

  • Security, which should be at the row level according to the privileges of the logged-in user.
  • Conflict resolution, where CRDT or AI can be very useful.
  • Data/Driver size, since the local DB is expected to adapt to machines with limited storage.

I'm not sure I understand this part. Security isn't a client responsibility. Conflict resolutions depends on the use-case. Data lifecycle management is a shared responsibility.

This is why SQLite with a decoupled persistence module, fits naturally into this architecture, especially in projects like the one I'm currently working on, which is a commercial app for small teams.

I agree that loose coupling can help to work with a variety of client-implementations (like electric does) to optimize for a variety of use-cases. However, this loose coupling also implies that there's no need for both the server and the client to use SQLite. The server can use DB X, client A can use storage solution Y, and client B can use storage solution Z. Empirically, Electric uses Postgres on the server and supports SQLite, PGLite and TanStack DB on the client. Electric could choose to support other server-side options in the future.

I am starting implementations with 2 toolchains and in the coming weeks, as I progress, I will have concrete experiences with this architecture.

That's awesome. I really do appreciate our discussion. Looking forward to more 🙏

@aMarCruz
Copy link

aMarCruz commented Dec 8, 2025

Problems to solve in this model, mostly in the client:

  • Security, which should be at the row level according to the privileges of the logged-in user.
  • Conflict resolution, where CRDT or AI can be very useful.
  • Data/Driver size, since the local DB is expected to adapt to machines with limited storage.

I'm not sure I understand this part. Security isn't a client responsibility. Conflict resolutions depends on the use-case. Data lifecycle management is a shared responsibility.

Unfortunately, in the client-first model, just like the data itself, the security problem is also mirrored on the client side.

With local databases lacking embedded security, the data is exposed on every terminal, potentially accessible to multiple users/employees.

I remember how absurdly easy it was to read the dBASE format; things aren't so different today. Copy an SQLite DB onto a USB and you could have years' worth of an entire company's data in your pocket.

And yes, conflict resolution is shared, although it can occur before the network is accessible, for example, a user making simultaneous changes to more than one tab.

In any case, client-first Apps must be designed to work without a server for days. For those of us who are starting out in a world w/o Internet, this is not so difficult ;-)

@ignatz
Copy link
Contributor

ignatz commented Dec 12, 2025

Quick update here, just publishing v0.22.0 with multi-DB support.

Sorry it took that long, there were more moving parts than expected - delusions are a powerful tool :hide:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants