turbine-orm 0.79.1 → 0.80.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.
Files changed (71) hide show
  1. package/README.md +4 -4
  2. package/dist/checkout.d.ts +53 -0
  3. package/dist/checkout.js +78 -0
  4. package/dist/cjs/checkout.d.ts +53 -0
  5. package/dist/cjs/checkout.js +82 -0
  6. package/dist/cjs/cli/index.js +4 -0
  7. package/dist/cjs/cli/mcp.js +4 -0
  8. package/dist/cjs/cli/migrate.js +6 -0
  9. package/dist/cjs/cli/observe.js +8 -0
  10. package/dist/cjs/cli/studio.js +13 -1
  11. package/dist/cjs/client.d.ts +12 -2
  12. package/dist/cjs/client.js +61 -75
  13. package/dist/cjs/connection-guard.d.ts +120 -0
  14. package/dist/cjs/connection-guard.js +191 -0
  15. package/dist/cjs/errors.d.ts +26 -0
  16. package/dist/cjs/errors.js +85 -1
  17. package/dist/cjs/index.d.ts +1 -1
  18. package/dist/cjs/nested-write.d.ts +12 -2
  19. package/dist/cjs/nested-write.js +4 -10
  20. package/dist/cjs/pipeline.js +12 -7
  21. package/dist/cjs/plan-flip-probe.js +4 -0
  22. package/dist/cjs/powdb-shared.d.ts +22 -2
  23. package/dist/cjs/powdb-shared.js +27 -2
  24. package/dist/cjs/powdb.js +36 -37
  25. package/dist/cjs/powql.d.ts +51 -6
  26. package/dist/cjs/powql.js +199 -45
  27. package/dist/cjs/prisma-compat.js +28 -4
  28. package/dist/cjs/query/builder.d.ts +44 -24
  29. package/dist/cjs/query/builder.js +125 -66
  30. package/dist/cjs/query/deferred.d.ts +9 -0
  31. package/dist/cjs/query/option-surface.js +12 -0
  32. package/dist/cjs/query/types.d.ts +68 -4
  33. package/dist/cjs/query/writes.d.ts +39 -9
  34. package/dist/cjs/query/writes.js +72 -34
  35. package/dist/cjs/realtime.d.ts +46 -2
  36. package/dist/cjs/realtime.js +125 -20
  37. package/dist/cjs/schema-sql.js +6 -0
  38. package/dist/cli/index.js +4 -0
  39. package/dist/cli/mcp.js +4 -0
  40. package/dist/cli/migrate.js +6 -0
  41. package/dist/cli/observe.js +8 -0
  42. package/dist/cli/studio.js +13 -1
  43. package/dist/client.d.ts +12 -2
  44. package/dist/client.js +62 -76
  45. package/dist/connection-guard.d.ts +120 -0
  46. package/dist/connection-guard.js +183 -0
  47. package/dist/errors.d.ts +26 -0
  48. package/dist/errors.js +83 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/nested-write.d.ts +12 -2
  52. package/dist/nested-write.js +4 -10
  53. package/dist/pipeline.js +13 -8
  54. package/dist/plan-flip-probe.js +4 -0
  55. package/dist/powdb-shared.d.ts +22 -2
  56. package/dist/powdb-shared.js +25 -2
  57. package/dist/powdb.js +23 -24
  58. package/dist/powql.d.ts +51 -6
  59. package/dist/powql.js +200 -46
  60. package/dist/prisma-compat.js +28 -4
  61. package/dist/query/builder.d.ts +44 -24
  62. package/dist/query/builder.js +126 -67
  63. package/dist/query/deferred.d.ts +9 -0
  64. package/dist/query/option-surface.js +12 -0
  65. package/dist/query/types.d.ts +68 -4
  66. package/dist/query/writes.d.ts +39 -9
  67. package/dist/query/writes.js +71 -34
  68. package/dist/realtime.d.ts +46 -2
  69. package/dist/realtime.js +125 -20
  70. package/dist/schema-sql.js +6 -0
  71. package/package.json +5 -3
@@ -44,22 +44,22 @@ export declare function coerceWriteValue(qi: BuilderCtx, key: string, value: unk
44
44
  * return rows from non-RETURNING engines. Reuses the same parameterized WHERE
45
45
  * builder as reads, so no user value is interpolated.
46
46
  */
47
- export declare function buildReselectByWhere(qi: BuilderCtx, whereObj: Record<string, unknown>): {
47
+ export declare function buildReselectByWhere(qi: BuilderCtx, whereObj: Record<string, unknown>, projection?: WriteProjection): {
48
48
  sql: string;
49
49
  params: unknown[];
50
50
  };
51
- export declare function buildCreate<T extends object>(qi: BuilderCtx, args: CreateArgs<T>): DeferredQuery<T>;
51
+ export declare function buildCreate<T extends object>(qi: BuilderCtx, args: CreateArgs<T>, projection?: WriteProjection): DeferredQuery<T>;
52
52
  /**
53
53
  * Build the `'reselect'` plan for {@link buildCreate}: run the INSERT, then
54
54
  * `SELECT * WHERE pk = ?`. Returns `undefined` (skipped) unless the active
55
55
  * dialect's result strategy is `'reselect'`, so the PostgreSQL/RETURNING path
56
56
  * pays nothing. Not yet wired to a real non-RETURNING engine.
57
57
  */
58
- export declare function makeCreateReselect<T extends object>(qi: BuilderCtx, insertSql: string, insertParams: unknown[], data: Record<string, unknown>): DeferredQuery<T>['reselect'];
58
+ export declare function makeCreateReselect<T extends object>(qi: BuilderCtx, insertSql: string, insertParams: unknown[], data: Record<string, unknown>, projection?: WriteProjection): DeferredQuery<T>['reselect'];
59
59
  export declare function buildCreateMany<T extends object>(qi: BuilderCtx, args: CreateManyArgs<T>): DeferredQuery<T[]>;
60
- export declare function buildUpdate<T extends object>(qi: BuilderCtx, args: UpdateArgs<T>): DeferredQuery<T>;
61
- export declare function buildDelete<T extends object>(qi: BuilderCtx, args: DeleteArgs<T>): DeferredQuery<T>;
62
- export declare function buildUpsert<T extends object>(qi: BuilderCtx, args: UpsertArgs<T>): DeferredQuery<T>;
60
+ export declare function buildUpdate<T extends object>(qi: BuilderCtx, args: UpdateArgs<T>, projection?: WriteProjection): DeferredQuery<T>;
61
+ export declare function buildDelete<T extends object>(qi: BuilderCtx, args: DeleteArgs<T>, projection?: WriteProjection): DeferredQuery<T>;
62
+ export declare function buildUpsert<T extends object>(qi: BuilderCtx, args: UpsertArgs<T>, projection?: WriteProjection): DeferredQuery<T>;
63
63
  export declare function buildUpdateMany<T extends object>(qi: BuilderCtx, args: UpdateManyArgs<T>): DeferredQuery<{
64
64
  count: number;
65
65
  }>;
@@ -83,6 +83,36 @@ export declare function piiColumns(_qi: BuilderCtx, meta: TableMetadata): Set<st
83
83
  * exclusion; you may still write PII fields freely).
84
84
  */
85
85
  export declare function piiFields(_qi: BuilderCtx, meta: TableMetadata): string[];
86
+ /**
87
+ * A single-row write's caller-chosen return shape, from its `select` / `omit`.
88
+ *
89
+ * Resolved ONCE per call by builder.ts through `resolveProjection`, the same
90
+ * authority reads use, so a write and a read agree on every rule: a name must
91
+ * resolve to a column or the call throws E003, a relation name gets its own
92
+ * message, `select` must name something, `select` and `omit` are exclusive, the
93
+ * list is in TABLE order (a caller's key order must not mint distinct
94
+ * statements), and an explicit `select` of a PII column is the opt-in that
95
+ * returns it while `omit` leaves PII excluded. (builder.ts rather than this
96
+ * module because relations.ts already imports this one.)
97
+ *
98
+ * The point is bytes: `RETURNING *` sends back every column, a large JSON
99
+ * payload included, on every insert. Measured with a 3.1 KB jsonb column at
100
+ * concurrency 50, the same INSERT ran at 60,900/s with no RETURNING and at
101
+ * 34,500/s with `RETURNING *`.
102
+ */
103
+ export interface WriteProjection {
104
+ /** Unquoted column names, in table order. Never empty. */
105
+ readonly columns: readonly string[];
106
+ }
107
+ /**
108
+ * The write-SQL cache segment for a projection. EMPTY for the default shape, so
109
+ * every existing key is byte-identical; a projected statement differs only in
110
+ * its RETURNING/OUTPUT text, and without this segment two calls with the same
111
+ * SET and WHERE but different `select`s would share one cached statement, i.e.
112
+ * the second caller would get the first caller's columns. NUL is the delimiter
113
+ * because it is the one byte a PostgreSQL identifier cannot contain.
114
+ */
115
+ export declare function writeProjectionCacheSegment(projection: WriteProjection | undefined): string;
86
116
  /**
87
117
  * The `RETURNING` / `OUTPUT` selection for a write on this table. A table with
88
118
  * no PII column returns `'*'` (every column, byte-identical SQL to before);
@@ -119,13 +149,13 @@ export declare function piiFields(_qi: BuilderCtx, meta: TableMetadata): string[
119
149
  * time (`SET "updated_at" = $1, "updated_at" = $2`, PostgreSQL 42701).
120
150
  */
121
151
  export declare function applyUpdatedAtColumns(qi: BuilderCtx, data: Record<string, unknown>): Record<string, unknown>;
122
- export declare function writeReturningColumns(qi: BuilderCtx): ReturningSelection;
152
+ export declare function writeReturningColumns(qi: BuilderCtx, projection?: WriteProjection): ReturningSelection;
123
153
  /**
124
154
  * String form of {@link writeReturningColumns} for a `SELECT` list (the
125
155
  * `'reselect'` result strategy re-fetches via a SELECT, not RETURNING).
126
156
  * `'*'` when there is no PII column; otherwise the comma-joined quoted list.
127
157
  */
128
- export declare function writeReselectSelection(qi: BuilderCtx): string;
158
+ export declare function writeReselectSelection(qi: BuilderCtx, projection?: WriteProjection): string;
129
159
  /**
130
160
  * Parse a write's returned row (create/update/upsert/delete), then strip the
131
161
  * table's PII fields: the write-side read policy. On PII-tagged tables the
@@ -134,7 +164,7 @@ export declare function writeReselectSelection(qi: BuilderCtx): string;
134
164
  * no-op. Untagged tables incur only one `for` over a zero-length field list,
135
165
  * so behavior is unchanged.
136
166
  */
137
- export declare function parseWriteRow(qi: BuilderCtx, row: Record<string, unknown>): Record<string, unknown>;
167
+ export declare function parseWriteRow(qi: BuilderCtx, row: Record<string, unknown>, projection?: WriteProjection): Record<string, unknown>;
138
168
  /**
139
169
  * Reject any write against a view (H4). Views are introspected with
140
170
  * `isView: true` and are read-only in every engine; a write raises a
@@ -76,11 +76,11 @@ function utcDateTimeWrites(qi) {
76
76
  * return rows from non-RETURNING engines. Reuses the same parameterized WHERE
77
77
  * builder as reads, so no user value is interpolated.
78
78
  */
79
- export function buildReselectByWhere(qi, whereObj) {
79
+ export function buildReselectByWhere(qi, whereObj, projection) {
80
80
  const params = [];
81
81
  const clause = whereMod.buildWhereClause(qi, whereObj, params);
82
82
  const where = clause ? ` WHERE ${clause}` : '';
83
- return { sql: `SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, params };
83
+ return { sql: `SELECT ${writeReselectSelection(qi, projection)} FROM ${qi.q(qi.table)}${where}`, params };
84
84
  }
85
85
  /**
86
86
  * Build the all-defaults INSERT for a `data` that names no column, via the
@@ -100,7 +100,7 @@ export function buildReselectByWhere(qi, whereObj) {
100
100
  * A dialect predating the hook raises E017 rather than emitting SQL its engine
101
101
  * will reject.
102
102
  */
103
- function buildDefaultValuesInsert(qi, rowCount, skipDuplicates) {
103
+ function buildDefaultValuesInsert(qi, rowCount, skipDuplicates, projection) {
104
104
  const build = qi.dialect.buildDefaultValuesInsertStatement;
105
105
  if (!build) {
106
106
  throw new UnsupportedFeatureError('create/createMany with an empty data object', qi.dialect.name, 'This dialect has no all-defaults INSERT form; name at least one column in `data`.');
@@ -109,10 +109,10 @@ function buildDefaultValuesInsert(qi, rowCount, skipDuplicates) {
109
109
  table: qi.q(qi.table),
110
110
  rowCount,
111
111
  skipDuplicates,
112
- returning: writeReturningColumns(qi),
112
+ returning: writeReturningColumns(qi, projection),
113
113
  });
114
114
  }
115
- export function buildCreate(qi, args) {
115
+ export function buildCreate(qi, args, projection) {
116
116
  assertWritable(qi, 'create');
117
117
  assertNoGeneratedColumns(qi, args.data, 'create');
118
118
  const entries = writeEntries(qi, args.data);
@@ -122,12 +122,12 @@ export function buildCreate(qi, args) {
122
122
  const placeholders = entries.map(([k], i) => `${qi.p(i + 1)}${whereMod.enumCastSuffix(qi, qi.toColumn(k))}`);
123
123
  // `data: {}` (or all-undefined) names no column: insert a row of defaults.
124
124
  const sql = entries.length === 0
125
- ? buildDefaultValuesInsert(qi, 1)
125
+ ? buildDefaultValuesInsert(qi, 1, undefined, projection)
126
126
  : qi.dialect.buildInsertStatement({
127
127
  table: qi.q(qi.table),
128
128
  columns,
129
129
  valuePlaceholders: placeholders,
130
- returning: writeReturningColumns(qi),
130
+ returning: writeReturningColumns(qi, projection),
131
131
  });
132
132
  return {
133
133
  sql,
@@ -141,12 +141,12 @@ export function buildCreate(qi, args) {
141
141
  message: `create on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
142
142
  });
143
143
  }
144
- return parseWriteRow(qi, row);
144
+ return parseWriteRow(qi, row, projection);
145
145
  },
146
146
  tag: `${qi.table}.create`,
147
147
  // Non-RETURNING engines: INSERT, then re-fetch the new row by primary key
148
148
  // (provided value, else the driver's generated insert id).
149
- reselect: makeCreateReselect(qi, sql, params, args.data),
149
+ reselect: makeCreateReselect(qi, sql, params, args.data, projection),
150
150
  };
151
151
  }
152
152
  /**
@@ -155,7 +155,7 @@ export function buildCreate(qi, args) {
155
155
  * dialect's result strategy is `'reselect'`, so the PostgreSQL/RETURNING path
156
156
  * pays nothing. Not yet wired to a real non-RETURNING engine.
157
157
  */
158
- export function makeCreateReselect(qi, insertSql, insertParams, data) {
158
+ export function makeCreateReselect(qi, insertSql, insertParams, data, projection) {
159
159
  if (qi.dialect.resultStrategy !== 'reselect')
160
160
  return undefined;
161
161
  return async (exec) => {
@@ -172,7 +172,7 @@ export function makeCreateReselect(qi, insertSql, insertParams, data) {
172
172
  conds.push(`${qi.q(pk)} = ${qi.p(idx++)}`);
173
173
  }
174
174
  const where = conds.length > 0 ? ` WHERE ${conds.join(' AND ')}` : '';
175
- return exec(`SELECT ${writeReselectSelection(qi)} FROM ${qi.q(qi.table)}${where}`, selParams);
175
+ return exec(`SELECT ${writeReselectSelection(qi, projection)} FROM ${qi.q(qi.table)}${where}`, selParams);
176
176
  };
177
177
  }
178
178
  /**
@@ -331,7 +331,7 @@ export function buildCreateMany(qi, args) {
331
331
  tag: `${qi.table}.createMany`,
332
332
  };
333
333
  }
334
- export function buildUpdate(qi, args) {
334
+ export function buildUpdate(qi, args, projection) {
335
335
  assertWritable(qi, 'update');
336
336
  qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
337
337
  // `updatedAt`-tagged columns are filled in before anything reads `data`, so
@@ -374,7 +374,7 @@ export function buildUpdate(qi, args) {
374
374
  // version check that must still run.
375
375
  const hasSetData = Object.values(dataObj).some((v) => v !== undefined);
376
376
  if (!hasSetData && !lock) {
377
- const sel = buildReselectByWhere(qi, whereObj);
377
+ const sel = buildReselectByWhere(qi, whereObj, projection);
378
378
  return {
379
379
  sql: sel.sql,
380
380
  params: sel.params,
@@ -382,14 +382,16 @@ export function buildUpdate(qi, args) {
382
382
  const row = result.rows[0];
383
383
  if (!row)
384
384
  throw new NotFoundError({ table: qi.table, where: args.where, operation: 'update' });
385
- return parseWriteRow(qi, row);
385
+ return parseWriteRow(qi, row, projection);
386
386
  },
387
387
  tag: `${qi.table}.update`,
388
388
  };
389
389
  }
390
390
  const setFp = fingerprintSet(qi, dataObj);
391
391
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
392
- const ck = lock ? null : `u:${setFp}|${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
392
+ const ck = lock
393
+ ? null
394
+ : `u:${setFp}|${whereFp}${whereMod.globalFilterCacheSegment(qi)}${writeProjectionCacheSegment(projection)}`;
393
395
  const params = [];
394
396
  const buildSql = (freshParams) => {
395
397
  const setEntries = writeEntries(qi, dataObj);
@@ -410,7 +412,7 @@ export function buildUpdate(qi, args) {
410
412
  // `OUTPUT INSERTED.*` between SET and WHERE) override buildUpdateStatement;
411
413
  // absent → the trailing-clause PG/SQLite/MySQL form (byte-identical).
412
414
  // `returning` excludes PII columns on tagged tables (else '*').
413
- const returning = writeReturningColumns(qi);
415
+ const returning = writeReturningColumns(qi, projection);
414
416
  return qi.dialect.buildUpdateStatement
415
417
  ? qi.dialect.buildUpdateStatement({ table: qi.q(qi.table), setClauses, whereSql, returning })
416
418
  : `UPDATE ${qi.q(qi.table)} SET ${setClauses.join(', ')}${whereSql}${qi.dialect.buildReturningClause(returning)}`;
@@ -455,7 +457,7 @@ export function buildUpdate(qi, args) {
455
457
  operation: 'update',
456
458
  });
457
459
  }
458
- return parseWriteRow(qi, row);
460
+ return parseWriteRow(qi, row, projection);
459
461
  },
460
462
  tag: `${qi.table}.update`,
461
463
  preparedName,
@@ -475,13 +477,13 @@ export function buildUpdate(qi, args) {
475
477
  expectedVersion: lock.expected,
476
478
  });
477
479
  }
478
- const sel = buildReselectByWhere(qi, whereObj);
480
+ const sel = buildReselectByWhere(qi, whereObj, projection);
479
481
  return exec(sel.sql, sel.params);
480
482
  }
481
483
  : undefined,
482
484
  };
483
485
  }
484
- export function buildDelete(qi, args) {
486
+ export function buildDelete(qi, args, projection) {
485
487
  assertWritable(qi, 'delete');
486
488
  qi.currentSkip = resolveSkipGlobalFilters(args.skipGlobalFilters);
487
489
  // Prisma compound-unique selector → the column conjunction (before the guard).
@@ -495,7 +497,7 @@ export function buildDelete(qi, args) {
495
497
  assertMutationWhereIdentifiesOneRow(qi.tableMeta, qi.table, userWhere, 'delete');
496
498
  const whereObj = (whereMod.mergeGlobalFilter(qi, userWhere) ?? {});
497
499
  const whereFp = whereMod.fingerprintWhere(qi, whereObj);
498
- const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}`;
500
+ const ck = `d:${whereFp}${whereMod.globalFilterCacheSegment(qi)}${writeProjectionCacheSegment(projection)}`;
499
501
  const params = [];
500
502
  const buildSql = (freshParams) => {
501
503
  const clause = whereMod.buildWhereClause(qi, whereObj, freshParams);
@@ -503,7 +505,7 @@ export function buildDelete(qi, args) {
503
505
  // SQL Server injects `OUTPUT DELETED.*` between `DELETE FROM <t>` and WHERE;
504
506
  // absent override → the trailing-clause PG/SQLite/MySQL form (byte-identical).
505
507
  // `returning` excludes PII columns on tagged tables (else '*').
506
- const returning = writeReturningColumns(qi);
508
+ const returning = writeReturningColumns(qi, projection);
507
509
  return qi.dialect.buildDeleteStatement
508
510
  ? qi.dialect.buildDeleteStatement({ table: qi.q(qi.table), whereSql, returning })
509
511
  : `DELETE FROM ${qi.q(qi.table)}${whereSql}${qi.dialect.buildReturningClause(returning)}`;
@@ -523,7 +525,7 @@ export function buildDelete(qi, args) {
523
525
  operation: 'delete',
524
526
  });
525
527
  }
526
- return parseWriteRow(qi, row);
528
+ return parseWriteRow(qi, row, projection);
527
529
  },
528
530
  tag: `${qi.table}.delete`,
529
531
  preparedName: entry.name,
@@ -531,7 +533,7 @@ export function buildDelete(qi, args) {
531
533
  // by the same where, then run the DELETE, returning the captured row.
532
534
  reselect: qi.dialect.resultStrategy === 'reselect'
533
535
  ? async (exec) => {
534
- const sel = buildReselectByWhere(qi, whereObj);
536
+ const sel = buildReselectByWhere(qi, whereObj, projection);
535
537
  const pre = await exec(sel.sql, sel.params);
536
538
  await exec(entry.sql, params, entry.name);
537
539
  return pre;
@@ -539,7 +541,7 @@ export function buildDelete(qi, args) {
539
541
  : undefined,
540
542
  };
541
543
  }
542
- export function buildUpsert(qi, args) {
544
+ export function buildUpsert(qi, args, projection) {
543
545
  assertWritable(qi, 'upsert');
544
546
  assertNoGeneratedColumns(qi, args.create, 'upsert');
545
547
  assertNoGeneratedColumns(qi, args.update, 'upsert');
@@ -595,8 +597,23 @@ export function buildUpsert(qi, args) {
595
597
  // unqualified column is ambiguous and PostgreSQL rejected EVERY upsert on a
596
598
  // globally filtered table at parse time (42702), insert path included.
597
599
  let updateWhere;
600
+ 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
+ }
598
615
  if (qi.dialect.supportsUpsertUpdateWhere) {
599
- const gf = whereMod.resolveGlobalFilter(qi, qi.table);
616
+ const gf = upsertFilter;
600
617
  if (gf) {
601
618
  // Compiled through a scope whose FROM-item reference is ALREADY RENDERED
602
619
  // (`"users"`), which is what the target table is inside `ON CONFLICT ...
@@ -621,7 +638,7 @@ export function buildUpsert(qi, args) {
621
638
  conflictColumns,
622
639
  updateSetClauses: setClauses,
623
640
  updateWhere,
624
- returning: writeReturningColumns(qi),
641
+ returning: writeReturningColumns(qi, projection),
625
642
  });
626
643
  return {
627
644
  sql,
@@ -646,14 +663,14 @@ export function buildUpsert(qi, args) {
646
663
  : `upsert on "${qi.table}" returned no row from RETURNING *; this should never happen.`,
647
664
  });
648
665
  }
649
- return parseWriteRow(qi, row);
666
+ return parseWriteRow(qi, row, projection);
650
667
  },
651
668
  tag: `${qi.table}.upsert`,
652
669
  // Non-RETURNING engines: run the upsert, then re-fetch by the where keys.
653
670
  reselect: qi.dialect.resultStrategy === 'reselect'
654
671
  ? async (exec) => {
655
672
  await exec(sql, params);
656
- const sel = buildReselectByWhere(qi, (whereMod.mergeGlobalFilter(qi, upsertWhere) ?? {}));
673
+ const sel = buildReselectByWhere(qi, (whereMod.mergeGlobalFilter(qi, upsertWhere) ?? {}), projection);
657
674
  return exec(sel.sql, sel.params);
658
675
  }
659
676
  : undefined,
@@ -802,6 +819,17 @@ export function piiFields(_qi, meta) {
802
819
  }
803
820
  return out;
804
821
  }
822
+ /**
823
+ * The write-SQL cache segment for a projection. EMPTY for the default shape, so
824
+ * every existing key is byte-identical; a projected statement differs only in
825
+ * its RETURNING/OUTPUT text, and without this segment two calls with the same
826
+ * SET and WHERE but different `select`s would share one cached statement, i.e.
827
+ * the second caller would get the first caller's columns. NUL is the delimiter
828
+ * because it is the one byte a PostgreSQL identifier cannot contain.
829
+ */
830
+ export function writeProjectionCacheSegment(projection) {
831
+ return projection ? `|rt=${projection.columns.join('\u0000')}` : '';
832
+ }
805
833
  /**
806
834
  * The `RETURNING` / `OUTPUT` selection for a write on this table. A table with
807
835
  * no PII column returns `'*'` (every column, byte-identical SQL to before);
@@ -868,7 +896,9 @@ function namedColumns(meta, data) {
868
896
  }
869
897
  return out;
870
898
  }
871
- export function writeReturningColumns(qi) {
899
+ export function writeReturningColumns(qi, projection) {
900
+ if (projection)
901
+ return projection.columns.map((col) => qi.q(col));
872
902
  // The PK exemption used to be re-stated here as `|| pk.has(col)`. It now
873
903
  // lives in `piiColumns` so every projection inherits it and none can drift.
874
904
  const piiCols = piiColumns(qi, qi.tableMeta);
@@ -881,8 +911,8 @@ export function writeReturningColumns(qi) {
881
911
  * `'reselect'` result strategy re-fetches via a SELECT, not RETURNING).
882
912
  * `'*'` when there is no PII column; otherwise the comma-joined quoted list.
883
913
  */
884
- export function writeReselectSelection(qi) {
885
- const cols = writeReturningColumns(qi);
914
+ export function writeReselectSelection(qi, projection) {
915
+ const cols = writeReturningColumns(qi, projection);
886
916
  return cols === '*' ? '*' : cols.join(', ');
887
917
  }
888
918
  /**
@@ -893,10 +923,17 @@ export function writeReselectSelection(qi) {
893
923
  * no-op. Untagged tables incur only one `for` over a zero-length field list,
894
924
  * so behavior is unchanged.
895
925
  */
896
- export function parseWriteRow(qi, row) {
926
+ export function parseWriteRow(qi, row, projection) {
897
927
  const parsed = qi.parseRow(row, qi.table);
898
- for (const field of piiFields(qi, qi.tableMeta)) {
899
- delete parsed[field];
928
+ // A column the caller named in `select` is the opt-in (see WriteProjection),
929
+ // so the strip spares exactly the projected columns and nothing else.
930
+ const pii = piiColumns(qi, qi.tableMeta);
931
+ if (pii.size === 0)
932
+ return parsed;
933
+ const requested = projection ? new Set(projection.columns) : undefined;
934
+ for (const col of qi.tableMeta.columns) {
935
+ if (pii.has(col.name) && !requested?.has(col.name))
936
+ delete parsed[col.field];
900
937
  }
901
938
  return parsed;
902
939
  }
@@ -24,6 +24,18 @@
24
24
  * (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
25
25
  * `$listen` will surface a clear error rather than hang. `$notify` works
26
26
  * everywhere, it's a single round-trip `SELECT pg_notify(...)`.
27
+ *
28
+ * Connection loss:
29
+ *
30
+ * A subscription's connection is held indefinitely with no query in flight,
31
+ * so a database restart, failover or `pg_terminate_backend` reaches it only
32
+ * as an `'error'` event. That event used to have no listener, which exits the
33
+ * process. It is now guarded (connection-guard.ts), and the subscription
34
+ * RECONNECTS by default: the dead connection is destroyed, a fresh one is
35
+ * checked out with exponential backoff, and `LISTEN` is re-issued. Postgres
36
+ * does not queue notifications for a listener that is not connected, so
37
+ * anything NOTIFYed during the gap is gone for good; `onReconnect` is the
38
+ * caller's cue to resynchronise from the source of truth.
27
39
  */
28
40
  import type { PgCompatPool } from './client.js';
29
41
  /**
@@ -36,6 +48,34 @@ import type { PgCompatPool } from './client.js';
36
48
  export declare function validateChannel(channel: string): void;
37
49
  /** Handler invoked with the raw NOTIFY payload string (empty string if none). */
38
50
  export type NotificationHandler = (payload: string) => void;
51
+ /** Backoff for re-establishing a subscription whose connection was lost. */
52
+ export interface ListenReconnectOptions {
53
+ /** Delay before the first reconnect attempt. Default 100 ms. */
54
+ initialDelayMs?: number;
55
+ /** Ceiling for the doubling delay between attempts. Default 30,000 ms. */
56
+ maxDelayMs?: number;
57
+ }
58
+ /** Options for `$listen`. */
59
+ export interface ListenOptions {
60
+ /**
61
+ * Re-establish the subscription after its connection is lost (a restart,
62
+ * failover, compute suspend, `pg_terminate_backend`). Default `true`. With
63
+ * `false` the subscription ends at the first loss, after `onError`.
64
+ */
65
+ reconnect?: boolean | ListenReconnectOptions;
66
+ /**
67
+ * Called when the connection is lost and after each failed reconnect
68
+ * attempt. Defaults to one `console.error` line per event. The subscription
69
+ * keeps retrying unless `reconnect` is `false`; call `unsubscribe()` to stop.
70
+ */
71
+ onError?: (err: Error) => void;
72
+ /**
73
+ * Called once `LISTEN` is active again on a fresh connection. Notifications
74
+ * sent while the subscription was disconnected were NOT delivered and never
75
+ * will be, so this is where to resynchronise from the source of truth.
76
+ */
77
+ onReconnect?: () => void;
78
+ }
39
79
  /**
40
80
  * A live LISTEN subscription. Call `unsubscribe()` to UNLISTEN, detach the
41
81
  * handler, and release the dedicated connection back to the pool.
@@ -46,6 +86,7 @@ export interface Subscription {
46
86
  /**
47
87
  * Stop listening: runs `UNLISTEN "chan"`, removes the notification listener,
48
88
  * and releases the dedicated connection. Idempotent, safe to call twice.
89
+ * Also cancels a pending reconnect.
49
90
  */
50
91
  unsubscribe(): Promise<void>;
51
92
  }
@@ -59,12 +100,15 @@ export interface ActiveSubscription extends Subscription {
59
100
  }
60
101
  /**
61
102
  * Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
103
+ * A failure here is thrown to the `$listen` caller; only a connection lost
104
+ * AFTER the subscription is established is retried.
62
105
  *
63
106
  * @param pool the pg-compatible pool to check a long-lived client out of
64
107
  * @param channel channel name, MUST already be validated by the caller
65
108
  * @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
66
109
  * @param handler called with each notification's payload
67
- * @param onClosed invoked when the subscription releases, so the client can
110
+ * @param onClosed invoked when the subscription ends, so the client can
68
111
  * drop it from its active-subscription registry
112
+ * @param options reconnect policy and loss/reconnect callbacks
69
113
  */
70
- export declare function createSubscription(pool: PgCompatPool, channel: string, quotedChannel: string, handler: NotificationHandler, onClosed: (sub: ActiveSubscription) => void): Promise<ActiveSubscription>;
114
+ export declare function createSubscription(pool: PgCompatPool, channel: string, quotedChannel: string, handler: NotificationHandler, onClosed: (sub: ActiveSubscription) => void, options?: ListenOptions): Promise<ActiveSubscription>;