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
package/dist/errors.js CHANGED
@@ -1200,6 +1200,84 @@ const CONNECTION_ERROR_CODES = new Set([
1200
1200
  'CERT_HAS_EXPIRED',
1201
1201
  'ERR_TLS_CERT_ALTNAME_INVALID',
1202
1202
  ]);
1203
+ /**
1204
+ * The driver's own words for "this connection is gone", which carry NO code.
1205
+ *
1206
+ * node-postgres raises these as plain `Error`s with no SQLSTATE and no socket
1207
+ * code: the first when the socket closes under a live client, the next two for
1208
+ * any query sent to a client after that, the last two when a connect or an
1209
+ * orderly `end()` cuts a query off. Keyed on `.code` alone, wrapPgError handed
1210
+ * every one of them back untyped, so the commonest symptom of a database
1211
+ * restart was the one connection failure a `catch (e) { if (e instanceof
1212
+ * ConnectionError) ... }` did not see. Exact matches on the full message, and
1213
+ * only on code-less errors: these strings are pg's and have not changed across
1214
+ * the 8.x line, and nothing else produces them.
1215
+ */
1216
+ const PG_CONNECTION_LOSS_MESSAGES = new Set([
1217
+ 'Connection terminated unexpectedly',
1218
+ 'Client has encountered a connection error and is not queryable',
1219
+ 'Client was closed and is not queryable',
1220
+ 'Connection terminated',
1221
+ 'Connection terminated due to connection timeout',
1222
+ ]);
1223
+ /** The follow-on errors pg raises for a query sent to a client already known to be dead. */
1224
+ const PG_NOT_QUERYABLE_MESSAGES = new Set([
1225
+ 'Client has encountered a connection error and is not queryable',
1226
+ 'Client was closed and is not queryable',
1227
+ ]);
1228
+ /**
1229
+ * Report the error that KILLED a held connection instead of its follow-on.
1230
+ *
1231
+ * When a connection dies while a transaction callback is between queries, the
1232
+ * next query fails with "Client has encountered a connection error and is not
1233
+ * queryable", which says nothing about why. The connection guard recorded the
1234
+ * real cause (`lostWith`, e.g. 57P01 `terminating connection due to
1235
+ * administrator command`), so a not-queryable ConnectionError is swapped for
1236
+ * that one. Anything else passes through: an error the caller threw, or a
1237
+ * query that was IN FLIGHT when the connection died, which already carries the
1238
+ * server's own message.
1239
+ */
1240
+ export function explainConnectionLoss(err, lostWith) {
1241
+ if (!lostWith || !(err instanceof ConnectionError))
1242
+ return err;
1243
+ const cause = err.cause;
1244
+ if (!(cause instanceof Error) || !PG_NOT_QUERYABLE_MESSAGES.has(cause.message))
1245
+ return err;
1246
+ const original = wrapPgError(lostWith);
1247
+ return original instanceof ConnectionError ? original : err;
1248
+ }
1249
+ /**
1250
+ * Codes that mean an ESTABLISHED connection went away: the server ended the
1251
+ * backend (57P01, which is also what a dead idle connection hands the next
1252
+ * query, since pg attributes the FATAL it buffered to whatever is sent next),
1253
+ * or the peer reset the socket.
1254
+ */
1255
+ const STALE_CONNECTION_CODES = new Set(['57P01', 'ECONNRESET', 'EPIPE']);
1256
+ /** pg's code-less messages for the same thing. */
1257
+ const STALE_CONNECTION_MESSAGES = new Set(['Connection terminated unexpectedly', ...PG_NOT_QUERYABLE_MESSAGES]);
1258
+ /**
1259
+ * Whether `err` says a connection that was already open is gone, so the same
1260
+ * statement on a fresh connection can succeed. Accepts the raw driver error or
1261
+ * the {@link ConnectionError} wrapPgError made of it.
1262
+ *
1263
+ * Narrower than "is a ConnectionError" on purpose. A connection that could not
1264
+ * be OPENED (refused, DNS, auth, a connect timeout, 57P03 while the server
1265
+ * starts) will not open on an immediate second try either, and retrying it
1266
+ * only doubles the wait before the caller hears about it. Nor does it cover
1267
+ * `Connection terminated` without "unexpectedly", which is the pool itself
1268
+ * shutting down.
1269
+ */
1270
+ export function isStaleConnectionError(err) {
1271
+ const raw = err instanceof ConnectionError && err.cause instanceof Error ? err.cause : err;
1272
+ if (!raw || typeof raw !== 'object')
1273
+ return false;
1274
+ const code = raw.code;
1275
+ if (code)
1276
+ return STALE_CONNECTION_CODES.has(code);
1277
+ return raw instanceof Error && STALE_CONNECTION_MESSAGES.has(raw.message);
1278
+ }
1279
+ const CONNECTION_LOSS_HINT = 'The server or the network closed it (a restart, failover, idle timeout, compute suspend, or ' +
1280
+ 'pg_terminate_backend). The pool discards the connection, so retrying the operation opens a fresh one.';
1203
1281
  /**
1204
1282
  * Actionable next step per connection-class code, appended to the driver's own
1205
1283
  * message. The driver message states WHAT happened ("password authentication
@@ -1472,8 +1550,12 @@ export function wrapPgError(err) {
1472
1550
  if (!err || typeof err !== 'object')
1473
1551
  return err;
1474
1552
  const e = err;
1475
- if (!e.code)
1553
+ if (!e.code) {
1554
+ if (err instanceof Error && PG_CONNECTION_LOSS_MESSAGES.has(err.message)) {
1555
+ return new ConnectionError(`Database connection lost: ${err.message}. ${CONNECTION_LOSS_HINT}`, { cause: err });
1556
+ }
1476
1557
  return err;
1558
+ }
1477
1559
  switch (e.code) {
1478
1560
  case '23505': {
1479
1561
  const cols = e.detail ? parseColumnsFromDetail(e.detail) : undefined;
package/dist/index.d.ts CHANGED
@@ -46,7 +46,7 @@ export { executePipeline, type PipelineOptions, type PipelineResults, pipelineSu
46
46
  export { fingerprintPrismaSchema } from './prisma-schema-fingerprint.js';
47
47
  export { type AggregateArgs, type AggregateResult, type ArrayFilter, AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN_PENALTY_MS_PER_ROW, AUTO_TO_ONE_JOIN_MAX_ROWS, AUTO_TO_ONE_JOIN_ROWS_MAX, AUTO_TO_ONE_JOIN_ROWS_MIN, type ColumnRef, type ConnectOrCreateOp, type CountArgs, type CreateArgs, type CreateDataInput, type CreateManyArgs, type DeferredQuery, type DeleteArgs, type DeleteManyArgs, type FieldResult, type FindManyArgs, type FindManyStreamArgs, type FindUniqueArgs, type GlobalFilters, type GroupByAggregateSpec, type GroupByArgs, type GroupByDistinctOn, type GroupByResult, type HavingClause, type JsonEncoding, type JsonFilter, type JsonPathAggregateTarget, type JsonPathGroupKey, type JsonPathOrderBy, type MiddlewareFn, type NestedCreateOp, type NestedUpdateOp, type NestedUpdateOpItem, type NestedUpsertOpItem, type OmitResult, type OrderByClause, type OrderByObject, type OrderBySpec, type OrderDirection, type PrivilegeOption, type QueryEvent, type QueryEventListener, QueryInterface, type QueryResult, type RelationDescriptor, type RelationFilter, type RelationLoadStrategy, type RelationOrderBy, type RelationOrderByChain, type RelationPickBy, type RelationPickOrderBy, type SelectResult, type SkipGlobalFilters, type TemporalInfinityReading, type TextSearchFilter, type TypedWithClause, UNSAFE, type Unsafe, type UpdateArgs, type UpdateDataInput, type UpdateInput, type UpdateManyArgs, type UpdateOperatorInput, type UpsertArgs, type VectorDistanceFilter, type VectorFilter, type VectorMetric, type VectorOrderBy, type VectorOrderByDistance, type WhereClause, type WhereOperator, type WhereValue, type WithClause, type WithOptions, type WithOrderByObject, type WithResult, } from './query/index.js';
48
48
  export { MAX_QUERY_TAG_LENGTH, RAW_QUERY_MODEL } from './query-events.js';
49
- export { type ActiveSubscription, type NotificationHandler, type Subscription, validateChannel } from './realtime.js';
49
+ export { type ActiveSubscription, type ListenOptions, type ListenReconnectOptions, type NotificationHandler, type Subscription, validateChannel, } from './realtime.js';
50
50
  export type { CheckMetadata, ColumnMetadata, IndexMetadata, PrismaCompatMap, PrismaModelMap, PrismaRelationMap, PrismaSchemaSource, ReferentialAction, RelationDef, SchemaMetadata, TableMetadata, } from './schema.js';
51
51
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, withDbFieldNames, } from './schema.js';
52
52
  export { applyManyToManyRelations, type CheckDef, ColumnBuilder, type ColumnConfig, type ColumnDef, type ColumnIndexDef, type ColumnType, type ColumnTypeName, column, type DefineSchemaOptions, type DocFieldIndexDef, defineSchema, isDocFieldIndexDef, type ManyToManyDef, type ReferenceDef, type SchemaDef, type SchemaIndexDef, type TableDef, table, } from './schema-builder.js';
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ export { AUTO_ASSUMED_ROUND_TRIP_MS, AUTO_COUNT_BATCH_MIN_PARENT_ROWS, AUTO_JOIN
58
58
  // $on('query') event metadata: raw-statement model name, $tag() label limit
59
59
  export { MAX_QUERY_TAG_LENGTH, RAW_QUERY_MODEL } from './query-events.js';
60
60
  // Realtime, LISTEN/NOTIFY pub/sub
61
- export { validateChannel } from './realtime.js';
61
+ export { validateChannel, } from './realtime.js';
62
62
  // Schema utilities
63
63
  export { camelToSnake, isDateType, normalizeKeyColumns, pgArrayType, pgTypeToTs, singularize, snakeToCamel, snakeToPascal, withDbFieldNames, } from './schema.js';
64
64
  // Schema builder, define schemas in TypeScript
@@ -137,10 +137,20 @@ export declare function createManyShapeRuns<T extends Record<string, unknown>>(r
137
137
  * operation (create, connect, connectOrCreate), and finally reads back the
138
138
  * full tree using `findUnique` with an auto-built `with` clause.
139
139
  */
140
- export declare function executeNestedCreate(ctx: NestedWriteContext, tableName: string, data: Record<string, unknown>, depth?: number, path?: string[]): Promise<Record<string, unknown>>;
140
+ /**
141
+ * The caller's `select` / `omit` for the TOP-LEVEL row of a nested write. Only
142
+ * the final read-back applies it (see executeNestedCreate), so it narrows the
143
+ * returned scalars exactly as it does on a read, while every intermediate
144
+ * statement keeps the full row it needs for keys.
145
+ */
146
+ export interface NestedReturnShape {
147
+ select?: Record<string, boolean>;
148
+ omit?: Record<string, boolean>;
149
+ }
150
+ export declare function executeNestedCreate(ctx: NestedWriteContext, tableName: string, data: Record<string, unknown>, depth?: number, path?: string[], returnShape?: NestedReturnShape): Promise<Record<string, unknown>>;
141
151
  /**
142
152
  * Tree-walking update: updates the parent row with scalar data, then
143
153
  * processes each relation operation (create, connect, connectOrCreate,
144
154
  * disconnect, set, delete), and reads back the full tree.
145
155
  */
146
- export declare function executeNestedUpdate(ctx: NestedWriteContext, tableName: string, where: Record<string, unknown>, data: Record<string, unknown>, depth?: number, path?: string[]): Promise<Record<string, unknown>>;
156
+ export declare function executeNestedUpdate(ctx: NestedWriteContext, tableName: string, where: Record<string, unknown>, data: Record<string, unknown>, depth?: number, path?: string[], returnShape?: NestedReturnShape): Promise<Record<string, unknown>>;
@@ -828,15 +828,7 @@ function rethrowAsNotRelated(err, op, relName, rel, target) {
828
828
  }
829
829
  throw err;
830
830
  }
831
- // ---------------------------------------------------------------------------
832
- // executeNestedCreate
833
- // ---------------------------------------------------------------------------
834
- /**
835
- * Tree-walking create: inserts the parent row, then processes each relation
836
- * operation (create, connect, connectOrCreate), and finally reads back the
837
- * full tree using `findUnique` with an auto-built `with` clause.
838
- */
839
- export async function executeNestedCreate(ctx, tableName, data, depth = 0, path = []) {
831
+ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path = [], returnShape) {
840
832
  if (depth > MAX_DEPTH) {
841
833
  throw new CircularRelationError(path);
842
834
  }
@@ -902,6 +894,7 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
902
894
  const fullRow = await ctx.tx.table(tableName).findUnique({
903
895
  where: pkWhere(tableMeta, parentRow),
904
896
  with: withClause,
897
+ ...returnShape,
905
898
  });
906
899
  return (fullRow ?? parentRow);
907
900
  }
@@ -913,7 +906,7 @@ export async function executeNestedCreate(ctx, tableName, data, depth = 0, path
913
906
  * processes each relation operation (create, connect, connectOrCreate,
914
907
  * disconnect, set, delete), and reads back the full tree.
915
908
  */
916
- export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path = []) {
909
+ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0, path = [], returnShape) {
917
910
  if (depth > MAX_DEPTH) {
918
911
  throw new CircularRelationError(path);
919
912
  }
@@ -1008,6 +1001,7 @@ export async function executeNestedUpdate(ctx, tableName, where, data, depth = 0
1008
1001
  const fullRow = await ctx.tx.table(tableName).findUnique({
1009
1002
  where: pkWhere(tableMeta, parentRow),
1010
1003
  with: readBackWith(ctx.schema, tableName, relations),
1004
+ ...returnShape,
1011
1005
  });
1012
1006
  return (fullRow ?? parentRow);
1013
1007
  }
package/dist/pipeline.js CHANGED
@@ -18,8 +18,9 @@
18
18
  * Sequential fallback covers HTTP-based drivers (Neon HTTP, Vercel Postgres, Cloudflare
19
19
  * Hyperdrive), mock pools in tests, and any pool that doesn't expose pg internals.
20
20
  */
21
+ import { guardCheckout } from './connection-guard.js';
21
22
  import { postgresDialect } from './dialect.js';
22
- import { PipelineError, TurbineError, wrapPgError } from './errors.js';
23
+ import { explainConnectionLoss, PipelineError, TurbineError, wrapPgError } from './errors.js';
23
24
  import { pipelineClientNeedsDiscard, runPipelined, supportsExtendedPipeline, } from './pipeline-submittable.js';
24
25
  /**
25
26
  * Execute queries sequentially on an already-acquired connection.
@@ -202,6 +203,9 @@ export async function executePipeline(pool, queries, options) {
202
203
  catch (err) {
203
204
  throw wrapPgError(err);
204
205
  }
206
+ // Guarded for the checkout window, see connection-guard.ts: a connection that
207
+ // dies mid-batch emits 'error', which with no listener exits the process.
208
+ const checkout = guardCheckout(client);
205
209
  try {
206
210
  if (supportsExtendedPipeline(client)) {
207
211
  // Real pipeline path, uses extended-query protocol wire methods
@@ -223,8 +227,8 @@ export async function executePipeline(pool, queries, options) {
223
227
  // driver error that must not escape with a SQLSTATE sitting in the same
224
228
  // `.code` slot Turbine puts TURBINE_E0NN in.
225
229
  if (err instanceof TurbineError)
226
- throw err;
227
- throw wrapPgError(err);
230
+ throw explainConnectionLoss(err, checkout.lostWith);
231
+ throw explainConnectionLoss(wrapPgError(err), checkout.lostWith);
228
232
  }
229
233
  finally {
230
234
  // A client the pipeline could not return to a clean state is released WITH
@@ -235,10 +239,10 @@ export async function executePipeline(pool, queries, options) {
235
239
  // normally is what turned one failed batch into `25P02` on somebody else's
236
240
  // query.
237
241
  if (pipelineClientNeedsDiscard(client)) {
238
- client.release(new Error('turbine: pipeline connection left an open transaction and was discarded'));
242
+ checkout.release(new Error('turbine: pipeline connection left an open transaction and was discarded'));
239
243
  }
240
244
  else {
241
- client.release();
245
+ checkout.release();
242
246
  }
243
247
  }
244
248
  }
@@ -250,15 +254,16 @@ export async function executePipeline(pool, queries, options) {
250
254
  * Note: This acquires and immediately releases a connection to inspect it.
251
255
  */
252
256
  export async function pipelineSupported(pool) {
253
- let client;
257
+ let checkout;
254
258
  try {
255
- client = await pool.connect();
259
+ const client = await pool.connect();
260
+ checkout = guardCheckout(client);
256
261
  return supportsExtendedPipeline(client);
257
262
  }
258
263
  catch {
259
264
  return false;
260
265
  }
261
266
  finally {
262
- client?.release();
267
+ checkout?.release();
263
268
  }
264
269
  }
@@ -76,6 +76,7 @@
76
76
  *
77
77
  * @module
78
78
  */
79
+ import { guardConnection } from './connection-guard.js';
79
80
  import { quoteIdent } from './query/utils.js';
80
81
  /** An empty result, which keeps every finding. Used when probing is off. */
81
82
  export function emptyFlipProbeResult() {
@@ -220,6 +221,9 @@ export async function probePlanFlips(options) {
220
221
  }
221
222
  const { Client } = (await import('pg')).default;
222
223
  const client = new Client({ connectionString: options.connectionString });
224
+ // A dropped connection must fail the probe (which then reports 'unknown'),
225
+ // not exit the process through an unheard 'error' event.
226
+ guardConnection(client);
223
227
  try {
224
228
  await client.connect();
225
229
  // READ ONLY is belt-and-braces: EXPLAIN without ANALYZE cannot write, and the
@@ -19,8 +19,9 @@
19
19
  *
20
20
  * Everything here is re-exported by powdb.ts under its original name, so the
21
21
  * public `turbine-orm/powdb` surface is byte-identical to before the split.
22
- * The few helpers powdb.ts consumes but never published (`isDateColumn`) are
23
- * exported from this module and NOT re-exported from powdb.ts.
22
+ * The few helpers powdb.ts consumes but never published (`isDateColumn`,
23
+ * `parsePowdbSemver`, `atLeastVersion`) are exported from this module and NOT
24
+ * re-exported from powdb.ts.
24
25
  *
25
26
  * @module
26
27
  */
@@ -140,6 +141,25 @@ export interface PowdbCapabilities {
140
141
  }
141
142
  /** The feature-gate capability keys (everything except the version/nativeRaw metadata). */
142
143
  type PowdbFeatureKey = 'jsonDocs' | 'docFieldIndexes' | 'introspection' | 'serverJoins' | 'nestedProjections' | 'entityLinks' | 'linkIntrospection' | 'linkPaths' | 'datetimeCompare' | 'projectedCountNonNull';
144
+ /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
145
+ export declare function parsePowdbSemver(version: string | undefined | null): {
146
+ major: number;
147
+ minor: number;
148
+ patch: number;
149
+ } | null;
150
+ /**
151
+ * Is `sem` at least `major.minor.patch`? PATCH-AWARE: `patch` defaults to `0`,
152
+ * so a two-component floor (`atLeastVersion(sem, 0, 19)`) behaves exactly as the
153
+ * old major/minor comparison did (matches every patch of 0.19), while a
154
+ * three-component floor (`atLeastVersion(sem, 0, 19, 1)`) additionally requires
155
+ * the patch, the distinction the link lanes need (0.19.1, never 0.19.0). Every
156
+ * existing two-argument call keeps its prior semantics unchanged.
157
+ */
158
+ export declare function atLeastVersion(sem: {
159
+ major: number;
160
+ minor: number;
161
+ patch: number;
162
+ }, major: number, minor: number, patch?: number): boolean;
143
163
  /**
144
164
  * Trusted-caller default: every FEATURE gate on, engine version unknown. Used
145
165
  * for a directly-constructed {@link PowdbPool} / {@link PowdbEmbeddedPool} that
@@ -19,8 +19,9 @@
19
19
  *
20
20
  * Everything here is re-exported by powdb.ts under its original name, so the
21
21
  * public `turbine-orm/powdb` surface is byte-identical to before the split.
22
- * The few helpers powdb.ts consumes but never published (`isDateColumn`) are
23
- * exported from this module and NOT re-exported from powdb.ts.
22
+ * The few helpers powdb.ts consumes but never published (`isDateColumn`,
23
+ * `parsePowdbSemver`, `atLeastVersion`) are exported from this module and NOT
24
+ * re-exported from powdb.ts.
24
25
  *
25
26
  * @module
26
27
  */
@@ -60,6 +61,28 @@ export class PowdbJsonParam {
60
61
  this.column = column;
61
62
  }
62
63
  }
64
+ /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
65
+ export function parsePowdbSemver(version) {
66
+ const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version ?? '').trim());
67
+ if (!m)
68
+ return null;
69
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
70
+ }
71
+ /**
72
+ * Is `sem` at least `major.minor.patch`? PATCH-AWARE: `patch` defaults to `0`,
73
+ * so a two-component floor (`atLeastVersion(sem, 0, 19)`) behaves exactly as the
74
+ * old major/minor comparison did (matches every patch of 0.19), while a
75
+ * three-component floor (`atLeastVersion(sem, 0, 19, 1)`) additionally requires
76
+ * the patch, the distinction the link lanes need (0.19.1, never 0.19.0). Every
77
+ * existing two-argument call keeps its prior semantics unchanged.
78
+ */
79
+ export function atLeastVersion(sem, major, minor, patch = 0) {
80
+ if (sem.major !== major)
81
+ return sem.major > major;
82
+ if (sem.minor !== minor)
83
+ return sem.minor > minor;
84
+ return sem.patch >= patch;
85
+ }
63
86
  /**
64
87
  * Minimum engine version each gated feature needs, for the E017 hint text.
65
88
  * Most gates carry a `major.minor` floor (patch-insensitive); the two link
package/dist/powdb.js CHANGED
@@ -57,7 +57,7 @@ import { TurbineClient, } from './client.js';
57
57
  import { postgresDialect } from './dialect.js';
58
58
  import { ConnectionError, malformedConnectionStringMessage, NotNullViolationError, ReadOnlyError, TimeoutError, UniqueConstraintError, UnsupportedFeatureError, ValidationError, } from './errors.js';
59
59
  import importOptionalPeer from './optional-peer-import.cjs';
60
- import { ALL_POWDB_CAPABILITIES, isDateColumn, PowdbFloatParam, PowdbJsonParam, powqlColumnType, quotePowqlIdent, requireCapability, } from './powdb-shared.js';
60
+ import { ALL_POWDB_CAPABILITIES, atLeastVersion, isDateColumn, PowdbFloatParam, PowdbJsonParam, parsePowdbSemver, powqlColumnType, quotePowqlIdent, requireCapability, } from './powdb-shared.js';
61
61
  import { shouldWarnOnce, WARN_NS } from './query/warn-registry.js';
62
62
  import { normalizeKeyColumns } from './schema.js';
63
63
  // The shared PowDB primitives live in a leaf module (see powdb-shared.ts): this
@@ -206,28 +206,6 @@ export function assertSupportedPowdbVersion(version) {
206
206
  * powql.ts.
207
207
  */
208
208
  export const POWQL_MAX_NESTING_DEPTH = 64;
209
- /** Parse a PowDB semver prefix (`0.13.0`, `0.13`, `1.2.3-rc`) into components, or `null`. */
210
- function parsePowdbSemver(version) {
211
- const m = /^(\d+)\.(\d+)(?:\.(\d+))?/.exec(String(version ?? '').trim());
212
- if (!m)
213
- return null;
214
- return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3] ?? 0) };
215
- }
216
- /**
217
- * Is `sem` at least `major.minor.patch`? PATCH-AWARE: `patch` defaults to `0`,
218
- * so a two-component floor (`atLeastVersion(sem, 0, 19)`) behaves exactly as the
219
- * old major/minor comparison did (matches every patch of 0.19), while a
220
- * three-component floor (`atLeastVersion(sem, 0, 19, 1)`) additionally requires
221
- * the patch, the distinction the link lanes need (0.19.1, never 0.19.0). Every
222
- * existing two-argument call keeps its prior semantics unchanged.
223
- */
224
- function atLeastVersion(sem, major, minor, patch = 0) {
225
- if (sem.major !== major)
226
- return sem.major > major;
227
- if (sem.minor !== minor)
228
- return sem.minor > minor;
229
- return sem.patch >= patch;
230
- }
231
209
  /**
232
210
  * Derive {@link PowdbCapabilities} from an engine version string. A non-semver /
233
211
  * unknown version turns every gate OFF (the E017 hint then tells the caller to
@@ -332,6 +310,19 @@ export function powqlSchemaDDL(schema, opts = {}) {
332
310
  // m2m junction's `(source_id, target_id)`) marks its columns `required` but
333
311
  // cannot enforce the tuple's uniqueness at the engine level.
334
312
  const pkIsSingle = meta.primaryKey.length === 1;
313
+ // The primary key's OWN index: introspection lists it in `indexes` (the
314
+ // `<table>_pkey` a PRIMARY KEY constraint creates), so a composite key used
315
+ // to reach the composite-index refusal below even though the type body
316
+ // already handles it. Identified by what it guarantees rather than by name:
317
+ // a full (non-partial) unique index over exactly the PK's column SET states
318
+ // the PK constraint and nothing else, whatever order it lists the columns in.
319
+ // Anything short of that (non-unique, partial, a subset or superset) is a
320
+ // genuine composite index and is still refused.
321
+ const isPrimaryKeyIndex = (idx) => idx.unique &&
322
+ !idx.partial &&
323
+ !idx.docPath &&
324
+ new Set(idx.columns).size === pkSet.size &&
325
+ idx.columns.every((c) => pkSet.has(c));
335
326
  const fields = meta.columns.map((col) => {
336
327
  const powqlType = powqlColumnType(col);
337
328
  // Gate `json` columns behind the engine's jsonDocs capability when a
@@ -385,8 +376,16 @@ export function powqlSchemaDDL(schema, opts = {}) {
385
376
  stmts.push(`alter ${quotePowqlIdent(meta.name)} add ${kind} (.${quotePowqlIdent(column)}${segs})`);
386
377
  }
387
378
  else {
379
+ // The composite PK's index is already the type body's `required` columns
380
+ // (see isPrimaryKeyIndex); a single-column PK's index is skipped by the
381
+ // emittedUnique check below.
382
+ if (idx.columns.length > 1 && isPrimaryKeyIndex(idx))
383
+ continue;
388
384
  // Plain column index. PowDB has no composite index (`add index` takes a
389
- // single `.column`), so a multi-column entry is a typed E017.
385
+ // single `.column`), so a multi-column entry is a typed E017. That
386
+ // includes a composite UNIQUE index other than the PK: it is an
387
+ // integrity rule the engine cannot enforce, so it is refused rather than
388
+ // dropped without a word.
390
389
  if (idx.columns.length !== 1) {
391
390
  throw new UnsupportedFeatureError('composite indexes', 'PowDB', `PowDB has no composite index. Index "${idx.name}" on ${meta.name} lists ` +
392
391
  `${idx.columns.length} columns; declare a single-column index (or a doc-field index) instead.`);
package/dist/powql.d.ts CHANGED
@@ -499,6 +499,21 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
499
499
  * becomes a no-op like {@link parseWriteRow} on the SQL engines.
500
500
  */
501
501
  private stripWritePii;
502
+ /**
503
+ * A single-row write's `select` / `omit`, resolved by {@link projectionPlan}
504
+ * so the rules and messages are the read path's, and the SQL engines' (an
505
+ * unknown name is E003, `select` must name something, the pair is refused).
506
+ * `undefined` for the default return shape. Resolved BEFORE the write is sent,
507
+ * so a bad projection writes nothing.
508
+ *
509
+ * The narrowing itself happens on the returned row ({@link shapeWriteRow}):
510
+ * PowQL's `returning` takes no column list (see {@link stripWritePii}), so
511
+ * unlike the SQL engines the unselected columns still cross the wire here.
512
+ * The RESULT is identical across engines; the byte saving is SQL-only.
513
+ */
514
+ private writeReturnPlan;
515
+ /** Apply a write's return plan to its row, or the default PII strip without one. */
516
+ private shapeWriteRow;
502
517
  /** `{ .c1, .c2, … }` projection clause. */
503
518
  private projection;
504
519
  /**
@@ -874,12 +889,21 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
874
889
  }>;
875
890
  upsert(args: UpsertArgs<T>): Promise<T>;
876
891
  /**
877
- * Composite-key upsert: PowQL's `upsert … on .col` only takes one conflict
878
- * column, so reselect by the full composite PK and update-or-create inside one
879
- * flat transaction (PowDB single-writer makes the read-then-write safe from
880
- * concurrent writers; the transaction makes it atomic with the write).
881
- */
882
- private upsertComposite;
892
+ * Upsert as a reselect-or-write inside one flat transaction, for every shape
893
+ * the native `upsert … on .col` statement cannot express: a conflict target
894
+ * other than a single-column primary key (it takes one column and PowDB has
895
+ * no composite unique), and a table under a global filter (its conflict
896
+ * branch takes no predicate). PowDB's single writer makes the read-then-write
897
+ * safe from concurrent writers; the transaction makes it atomic with the
898
+ * write.
899
+ *
900
+ * The row is looked up by the conflict columns with `create`'s values for
901
+ * them, which is what `ON CONFLICT (<cols>)` compares on the SQL engines. The
902
+ * find and the update run through the transaction's table interface, so a
903
+ * configured global filter applies to both exactly as it does to any other
904
+ * read or write, and `skipGlobalFilters` is forwarded to both.
905
+ */
906
+ private upsertLookupFirst;
883
907
  count(args?: CountArgs<T>): Promise<number>;
884
908
  /**
885
909
  * Gate ONE field of a per-field `_count`.
@@ -902,6 +926,27 @@ export declare class PowqlInterface<T extends object = Record<string, unknown>>
902
926
  * instead of values below 0.20, the same drift any stale-metadata query has.
903
927
  */
904
928
  private assertProjectedCountSupported;
929
+ /**
930
+ * Can `aggregate()` compute several aggregates in ONE statement on this engine?
931
+ *
932
+ * PowQL refuses a bare multi-aggregate projection (`T { a: sum(.x), b: sum(.y) }`
933
+ * is "aggregate function in an unsupported position" on every engine version),
934
+ * so the only one-statement form is a grouping over a literal key,
935
+ * `T filter … group 1 { agg_0: sum(.x), agg_1: sum(.y) }`, which yields a
936
+ * single group holding every row the filter matched. Measured against the
937
+ * embedded addon at 0.7.1 through 0.28: below 0.13 a literal group key does not
938
+ * parse, from 0.13 to 0.19.1 the grouped per-field `count` disagrees with the
939
+ * scalar `count(T { .col })` on a nullable column, and from 0.20 every
940
+ * aggregate kind (count / sum / avg / min / max over int, float, str, an
941
+ * all-null column, and several filters) answers identically on both wires.
942
+ *
943
+ * Derived from the PROBED engine version rather than from a capability flag,
944
+ * so an unprobed pool (`engineVersion: null`, e.g. an injected pool carrying
945
+ * {@link ALL_POWDB_CAPABILITIES}) keeps the per-field statements: this changes
946
+ * the emitted PowQL, and an older engine would reject it outright, the same
947
+ * probe-only discipline `nestedProjections` follows.
948
+ */
949
+ private get groupsAggregatesInOneStatement();
905
950
  aggregate(args: AggregateArgs<T>): Promise<AggregateResult<T>>;
906
951
  groupBy(args: GroupByArgs<T>): Promise<Record<string, unknown>[]>;
907
952
  /** Validate a JSON-path target (group key / aggregate target): non-empty array of keys/indexes. */