turbine-orm 0.80.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { NotFoundError, OptimisticLockError, UnsupportedFeatureError, ValidationError } from '../errors.js';
14
14
  import { camelToSnake, snakeToCamel } from '../schema.js';
15
- import { assertMutationWhereIdentifiesOneRow, expandCompoundUniqueWhere } from './compound-unique.js';
15
+ import { assertMutationWhereIdentifiesOneRow, expandCompoundUniqueWhere, upsertWhereMatchesCreate, } from './compound-unique.js';
16
16
  import { isUnmatchedPlainObject, UPDATE_OPERATOR_KEYS } from './filters.js';
17
17
  import { resolveSkipGlobalFilters, resolveUnsafeFlag } from './types.js';
18
18
  import { canonicalWriteEntries, coerceTemporalValue, resolveColumnName } from './utils.js';
@@ -541,6 +541,35 @@ export function buildDelete(qi, args, projection) {
541
541
  : undefined,
542
542
  };
543
543
  }
544
+ export function upsertLookupReason(qi, args) {
545
+ const where = (args.where ?? {});
546
+ const create = (args.create ?? {});
547
+ if (!upsertWhereMatchesCreate(qi.tableMeta, where, create))
548
+ return 'where-differs';
549
+ if (writeEntries(qi, (args.update ?? {})).length === 0)
550
+ return 'empty-update';
551
+ if (!qi.dialect.supportsUpsertUpdateWhere) {
552
+ qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
553
+ const filter = whereMod.resolveGlobalFilter(qi, qi.table);
554
+ if (filter && whereMod.buildRenderedRefWhere(qi, qi.table, qi.tableMeta, qi.q(qi.table), filter, [])) {
555
+ return 'global-filter';
556
+ }
557
+ }
558
+ return null;
559
+ }
560
+ /** The refusal `buildUpsert` raises for each reason, naming the way forward. */
561
+ function upsertStatementRefusal(qi, reason, where) {
562
+ const call = 'Call `upsert()` unbatched: it looks the row up by `where` first.';
563
+ if (reason === 'global-filter') {
564
+ return new UnsupportedFeatureError(`a batched upsert on "${qi.table}", which has a global filter,`, qi.dialect.name, `Its upsert statement cannot carry the filter. ${call} Or pass \`skipGlobalFilters: UNSAFE\`.`);
565
+ }
566
+ const keys = Object.keys(where).filter((k) => where[k] !== undefined);
567
+ return new ValidationError(`A batched upsert on "${qi.table}" cannot be one statement: ` +
568
+ (reason === 'empty-update'
569
+ ? 'its `update` is empty. '
570
+ : `\`where\` (${keys.join(', ')}) does not carry \`create\`'s values, which are what one statement matches on. `) +
571
+ call);
572
+ }
544
573
  export function buildUpsert(qi, args, projection) {
545
574
  assertWritable(qi, 'upsert');
546
575
  assertNoGeneratedColumns(qi, args.create, 'upsert');
@@ -565,6 +594,11 @@ export function buildUpsert(qi, args, projection) {
565
594
  // `allowFullTableScan: UNSAFE`, which is not an option on `UpsertArgs` at
566
595
  // all, so borrowing it here would name a way out that does not exist.
567
596
  assertMutationWhereIdentifiesOneRow(qi.tableMeta, qi.table, upsertWhere, 'upsert');
597
+ // `upsert()` never reaches here with a reason (it looks the row up instead),
598
+ // so this refuses only a batched upsert, which has no round trip to spare.
599
+ const lookupReason = upsertLookupReason(qi, args);
600
+ if (lookupReason)
601
+ throw upsertStatementRefusal(qi, lookupReason, upsertWhere);
568
602
  // Build the INSERT part from create data
569
603
  const createEntries = writeEntries(qi, args.create);
570
604
  const columns = createEntries.map(([k]) => qi.toSqlColumn(k));
@@ -598,20 +632,6 @@ export function buildUpsert(qi, args, projection) {
598
632
  // globally filtered table at parse time (42702), insert path included.
599
633
  let updateWhere;
600
634
  const upsertFilter = whereMod.resolveGlobalFilter(qi, qi.table);
601
- if (upsertFilter && !qi.dialect.supportsUpsertUpdateWhere) {
602
- // MySQL's `ON DUPLICATE KEY UPDATE` has no predicate slot and SQL Server's
603
- // MERGE cannot take the builder's column references there, so on those
604
- // engines the filter used to be DROPPED from the conflict update, silently.
605
- // A tenant-scoped upsert whose key matched another tenant's row updated
606
- // that row. Refused instead, when the filter would compile to anything:
607
- // the global-filter contract is that it scopes every update, and an
608
- // upsert that cannot honour it must not run as if it did.
609
- if (whereMod.buildRenderedRefWhere(qi, qi.table, qi.tableMeta, qi.q(qi.table), upsertFilter, [])) {
610
- throw new UnsupportedFeatureError(`upsert on "${qi.table}", which has a global filter,`, qi.dialect.name, "This engine's upsert statement cannot carry the filter on its conflict update, so it could update a row " +
611
- 'the filter hides. Use findUnique, then update or create, inside $transaction; or pass ' +
612
- '`skipGlobalFilters: UNSAFE` if the upsert is meant to reach every row.');
613
- }
614
- }
615
635
  if (qi.dialect.supportsUpsertUpdateWhere) {
616
636
  const gf = upsertFilter;
617
637
  if (gf) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.80.0",
3
+ "version": "0.81.0",
4
4
  "description": "Postgres-native TypeScript ORM, runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "//exports": "Each subpath declares its types PER CONDITION. A single shared top-level \"types\" resolves to the ESM declarations for `require` too, which is TS1479 (\"is an ES module ... cannot be require()d\") for any CJS consumer on moduleResolution node16/nodenext. The require condition points at dist/cjs, which ships its own {\"type\":\"commonjs\"} package.json, so those declarations are CJS declarations. Gated in CI by publint + @arethetypeswrong/cli + a real .cts consumer typecheck (see the package-types job in ci.yml).",
@@ -213,7 +213,7 @@
213
213
  "@types/pg": "^8.11.11",
214
214
  "@zvndev/powdb-client": "^0.20.0",
215
215
  "@zvndev/powdb-embedded": "^0.20.0",
216
- "c8": "^11.0.0",
216
+ "c8": "^12.0.0",
217
217
  "esbuild": "^0.28.2",
218
218
  "husky": "^9.1.7",
219
219
  "lint-staged": "^17.0.8",
@@ -242,9 +242,12 @@ where: { guildId: 4, batchRef: 'WB-0007' } // equivalent
242
242
 
243
243
  `update`, `delete` and `upsert` each require a `where` that identifies one row,
244
244
  by the same rule as `findUnique`, and anything else is `TURBINE_E003` naming the
245
- keys that would work. For an upsert the `where` **is** the `ON CONFLICT` target,
246
- so a non-unique predicate is refused rather than sent. Use `updateMany` and
247
- `deleteMany` for "every row matching a filter".
245
+ keys that would work. For an upsert the `where` names the row to update, and is
246
+ the `ON CONFLICT` target when `create` carries the same values, so a non-unique
247
+ predicate is refused rather than sent. When `create` does not carry them (a
248
+ `create` that leaves `id` to its sequence, say), `upsert` looks the row up by
249
+ `where` first, inside a transaction. Use `updateMany` and `deleteMany` for
250
+ "every row matching a filter".
248
251
 
249
252
  ## `distinct`
250
253