Skip to content

server: AddMany, and a rule for the RPCs that add - #11

Merged
lesomnus merged 2 commits into
mainfrom
add-many
Oct 17, 2026
Merged

lesomnus merged 2 commits into
mainfrom
add-many

Conversation

@lesomnus

Copy link
Copy Markdown
Member

protobuf-orm's add.many gives an entity AddMany beside its Add (protobuf-orm/protobuf-orm#3, protobuf-orm/protoc-gen-orm-service#3). This is the server's half, plus a rule a layer in front puts on both of the RPCs that add.

What changes

  • create. Add's body is split, and create is what both RPCs write with: the row a request becomes, as a builder not yet saved, and what the answer says about its edges. So an item cannot be written with a default or a key that an Add of it would not have.
  • AddMany:
    • It writes the builders with one INSERT for every few hundred rows: as many as fit in what a statement binds on every engine, and no more than 500.
    • It does so in one transaction, so all of the rows are written or none.
    • The rows come back in the order of the items.
    • A recorder is told about each row, By the AddMany, as an Add of each would have told it. An image the recorder asks for is read by the INSERT itself (sqlgraph: a batch reads back the rows it stored, each by its Id ent#11, Stored in a CreateBulk).
  • Refusals:
    • An item that cannot be a row, such as a key that is not sixteen bytes or a reference by alias that names nothing, is refused before anything is written, and the refusal names it.
    • A refusal by the database belongs to the whole statement and does not say which row. For example, a key that two items share is an AlreadyExists for the call.
    • max (1000 when unset) bounds the items in one call, and more are refused.
  • ItemError(i, err):
    • It puts items[i]: before the message.
    • For an InvalidArgument, it puts the fields of its google.rpc.BadRequest under items[i], adding a field when there was none.
    • It keeps the code, and adds no field to a NotFound.
  • <Entity>WithAddRule(next, rule) is what a layer in front has to say about a row an Add would write:
    • The layer says it once, and it is asked about an Add and about every item of an AddMany, in order.
    • The rule hands back the request to write: the one it was given, or a copy it changed, since the request belongs to whoever called.
    • An AddMany is handed on as one AddMany of what the rule answered.
    • If the layer overrode Add instead, its rule would silently not be asked about an AddMany the day the entity asks for one.
    • lesomnus/payday's gate and sink are the layers this is for, and nothing in it is payday's.
  • Fixtures and docs: internal/apptest asks for AddMany on user, seat (with a max of 3) and the three field entities. The README has a section on all of this, and the Recorder docs count AddMany.

Before merge

The pins are branch heads: protobuf-orm at #3, protoc-gen-orm-service at #3, and protobuf-orm/ent at #11, which internal/apptest uses. The fixtures need buf.build/orm/orm with many in it. Until those are merged and the module is pushed, this PR's CI cannot compile the fixtures. Locally I generated against the protobuf-orm checkout. Once all three are merged, I will re-pin them to their merge commits.

Tests

  • AddMany:
    • every item is added, answered in order, and found by a Get;
    • one INSERT, and two for 501 rows;
    • a call over the max is refused and nothing is written;
    • an item that cannot be a row is refused naming it, and nothing is written;
    • a reference by alias to nothing is a NotFound naming the item;
    • a reference by an Id nothing has is the database's NotFound for the call;
    • two items sharing a key are an AlreadyExists for the call;
    • no items means nothing is written or recorded.
  • Recording:
    • a change for every row, with the row as stored and no SELECT for it;
    • a recorder that refuses the second change takes every row back;
    • on PostgreSQL, the images are at the column's microsecond.
  • Same rows as Add: an item of values and defaults, and an item of a message, are written by AddMany as Add writes them.
  • The rule:
    • it is asked about an Add, and about every item in order;
    • it writes what the rule answers, not the caller's request;
    • the call is refused at the first item the rule refuses, naming it; nothing after that is asked, and nothing is written;
    • a call over the max is refused before the rule is asked at all.
  • ItemError: the cases above.
  • Mutations fail these tests: an AddMany that does not ask the rule, one INSERT whatever the rows, and a refusal without the item in it.
  • Locally: the root, runtime and internal/apptest suites, the last on PostgreSQL 17 as well.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk

protobuf-orm's `add.many` gives an entity AddMany beside its Add
(protobuf-orm/protobuf-orm#3, protobuf-orm/protoc-gen-orm-service#3),
and this is the server's half.

An item is written as an Add of it would be: Add's body is split, and
`create` -- the row a request becomes, as a builder not yet saved, and
what the answer says about its edges -- is what both of them write
with, so an item cannot be written with a default or a key an Add of it
would not have. AddMany writes the builders with one INSERT for every
few hundred rows, as many as fit in what a statement binds on every
engine, in one transaction: all of the rows or none. The rows come back
in the order of the items, and a recorder is told about each of them,
`By` the AddMany, as an Add of each would have told it -- an image it
asks for read by the INSERT itself (protobuf-orm/ent#11, `Stored` in a
CreateBulk).

An item that cannot be a row -- a key that is not sixteen bytes, a
reference by alias that names nothing -- is refused before anything is
written, naming it: `ItemError` puts `items[i]: ` before the message,
and the fields of an InvalidArgument's google.rpc.BadRequest under
`items[i]`, adding one when it had none. A refusal by the database is
the whole statement's and does not say which row: a key two items share
is an AlreadyExists for the call. `max` bounds the items, and more are
refused.

And `<Entity>WithAddRule(next, rule)`: what a layer in front has to say
about a row an Add would write, said once, and asked about an Add and
about every item of an AddMany in order. The rule hands back the
request to write -- the one it was given, or a copy it changed, since
the one it was handed belongs to whoever called -- and an AddMany is
handed on as one AddMany of what it answered. Written as an Add the
layer overrides instead, the rule would be silently not asked about an
AddMany the day its entity asked for one. lesomnus/payday's gate and
sink are the layers this is for, and nothing in it is theirs.

internal/apptest asks for AddMany on user, seat (with a max of 3) and
the three field entities.

- Every item added, answered in order, and as rows a Get finds; in one
  INSERT, and in two for 501 rows.
- Over the max refused, and nothing written; an item that cannot be a
  row refused naming it, and nothing written; a reference by alias to
  nothing, NotFound naming the item; a reference by an Id nothing has,
  the database's NotFound for the call; two items sharing a key, an
  AlreadyExists for the call; no items, nothing written or recorded.
- A change for every row, with the row as stored and no SELECT for it;
  a recorder that refuses the second takes every row back. On
  PostgreSQL, the images at the column's microsecond.
- An item of values and defaults, and one of a message, written by
  AddMany as Add writes them.
- The rule: asked about an Add and about every item in order, writing
  what it answers and not the caller's request; refused at the first
  item it refuses, naming it, nothing after asked and nothing written;
  a call over the max refused before it is asked at all.
- ItemError: the code kept, the item in the message, the fields under
  the item for an InvalidArgument and none added for a NotFound.

Mutations fail them: an AddMany that does not ask the rule, one INSERT
whatever the rows, a refusal without the item in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
…bd3c068

protobuf-orm/protobuf-orm#3 (3430237), protobuf-orm/protoc-gen-orm-service#3
(5234846) and protobuf-orm/ent#11 (5460d90) are merged, and
buf.build/orm/orm carries `rpc.add.many`. The fixtures that ask for an
AddMany compile against the registry's module now; nothing generated
moved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
@lesomnus
lesomnus merged commit 3d29dad into main Oct 17, 2026
2 checks passed
@lesomnus
lesomnus deleted the add-many branch October 17, 2026 21:59
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 17, 2026
…: orm 6bd3c068

protobuf-orm/protobuf-orm#3 (3430237), protobuf-orm/ent#11 (5460d90),
protobuf-orm/protoc-gen-orm-service#3 (5234846) and
protobuf-orm/protoc-gen-orm-ent#11 (3d29dad) are merged, and
buf.build/orm/orm carries `rpc.add.many` (6bd3c068). So the apptest's
schema compiles against the registry's module, and what was generated
against the checkout is what `pd gen --check` finds: nothing moved but
the ent runtime's version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
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.

1 participant