Problem
ModelInstance.save() picked INSERT or UPDATE with if (this._attributes[pk]). Three cases went the wrong way, and none of them raised an error:
| Call | Before | After |
|---|---|---|
Post.create({ id: 5, title }) | UPDATE matched nothing. Returned an instance with id 5, no row written | row 5 inserted |
Country.create({ code: 'PH', name }) (string key) | same: string-keyed models could not create rows at all | row inserted, key stays 'PH' |
(await Post.find(0)).set(...).save() | INSERT: wrote a copy of row 0 | row 0 updated |
select('title') row, edited, save() | INSERT: wrote a copy | throws Cannot save a model without a primary key |
All four reproduce on SQLite and on live Postgres.
Fix
- Instances track whether they exist as a row in the table. Every hydration site (
find,findMany,get/first, eager loads, belongsToMany,fresh) passestrue.create(),make()andreplicate()passfalse, and a successful insert flips it totrue. The flag is a required constructor argument, so the compiler makes each call site choose. - The INSERT writes a caller-supplied key. Neither existing loop picked it up unless the model declared the key as an attribute. A
guardedkey attribute is still left to the database. - After an insert, the driver's insert id is read back only for a generated key. For a supplied key it's the wrong value: the SQLite rowid, or
0on MySQL for a key without AUTO_INCREMENT. - A save that has changes but no key to address them now throws instead of inserting a copy. A save with no changes stays a no-op.
ModelInstance is exported only as a type, so the constructor change isn't public API.
Tests
test/orm.save-primary-key.test.ts: 12 tests. 8 fail onmain.test/orm.save-primary-key.pg.test.ts: the supplied-key, string-key and key-0cases against live Postgres, which inserts viaRETURNING. It fails onmainwith the same symptoms, including the duplicated0row.
Full suite: 2,286 pass / 0 fail / 10 skip. tsc clean. Lint adds no new warnings (334 on main as well).
Not covered: MySQL. There's no local server to execute against, so the lastInsertId guard there is reasoned from DriverExecutor.insert, not run.
🤖 Generated with Claude Code