turbine-orm 0.16.0 → 0.19.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 (43) hide show
  1. package/README.md +180 -12
  2. package/dist/adapters/cockroachdb.js +4 -2
  3. package/dist/adapters/index.js +4 -1
  4. package/dist/adapters/yugabytedb.js +4 -2
  5. package/dist/cjs/adapters/cockroachdb.js +4 -2
  6. package/dist/cjs/adapters/index.js +4 -1
  7. package/dist/cjs/adapters/yugabytedb.js +4 -2
  8. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  9. package/dist/cjs/cli/studio.js +35 -73
  10. package/dist/cjs/client.js +164 -0
  11. package/dist/cjs/errors.js +35 -5
  12. package/dist/cjs/generate.js +14 -3
  13. package/dist/cjs/index.js +10 -2
  14. package/dist/cjs/introspect.js +81 -0
  15. package/dist/cjs/nested-write.js +70 -6
  16. package/dist/cjs/query/builder.js +581 -17
  17. package/dist/cjs/realtime.js +147 -0
  18. package/dist/cjs/schema-builder.js +86 -0
  19. package/dist/cjs/schema.js +10 -0
  20. package/dist/cjs/typed-sql.js +149 -0
  21. package/dist/cli/studio-ui.generated.js +1 -1
  22. package/dist/cli/studio.js +35 -73
  23. package/dist/client.d.ts +120 -0
  24. package/dist/client.js +165 -1
  25. package/dist/errors.js +35 -5
  26. package/dist/generate.js +14 -3
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.js +5 -1
  29. package/dist/introspect.js +81 -0
  30. package/dist/nested-write.js +70 -6
  31. package/dist/query/builder.d.ts +104 -1
  32. package/dist/query/builder.js +582 -18
  33. package/dist/query/index.d.ts +1 -1
  34. package/dist/query/types.d.ts +126 -2
  35. package/dist/realtime.d.ts +71 -0
  36. package/dist/realtime.js +144 -0
  37. package/dist/schema-builder.d.ts +68 -1
  38. package/dist/schema-builder.js +85 -0
  39. package/dist/schema.d.ts +18 -1
  40. package/dist/schema.js +10 -0
  41. package/dist/typed-sql.d.ts +101 -0
  42. package/dist/typed-sql.js +145 -0
  43. package/package.json +17 -15
@@ -5,7 +5,7 @@
5
5
  * `import { … } from './query/index.js'` is a drop-in replacement for the
6
6
  * former monolithic `import { … } from './query.js'`.
7
7
  */
8
- export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
9
9
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { SqlCacheEntry } from './utils.js';
@@ -27,8 +27,9 @@ export interface WhereOperator<V = unknown> {
27
27
  * - A JSONB filter object ({ contains, equals, path, hasKey })
28
28
  * - An array filter object ({ has, hasEvery, hasSome, isEmpty })
29
29
  * - A text search filter object ({ search, config? })
30
+ * - A vector distance filter object ({ distance: { to, metric, lt } }) for pgvector columns
30
31
  */
31
- export type WhereValue<V = unknown> = V | WhereOperator<V> | JsonFilter | ArrayFilter | TextSearchFilter | null;
32
+ export type WhereValue<V = unknown> = V | WhereOperator<V> | JsonFilter | ArrayFilter | TextSearchFilter | VectorFilter | null;
32
33
  /**
33
34
  * Where clause type: each field can be a plain value, null, or operator object.
34
35
  * Special keys: OR for disjunctive conditions.
@@ -197,7 +198,7 @@ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClaus
197
198
  where?: WhereClause<T>;
198
199
  select?: S;
199
200
  omit?: O;
200
- orderBy?: Record<string, OrderDirection>;
201
+ orderBy?: OrderByClause;
201
202
  limit?: number;
202
203
  offset?: number;
203
204
  with?: W;
@@ -352,6 +353,59 @@ export interface CountArgs<T> {
352
353
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
353
354
  timeout?: number;
354
355
  }
356
+ /**
357
+ * Numeric comparison operators usable inside a `having` filter. A bare number
358
+ * is shorthand for equality (`COUNT(*) = $n`); the operator object supports
359
+ * range and inequality comparisons. Mirrors the numeric subset of
360
+ * {@link WhereOperator} so the same SQL machinery can be reused.
361
+ */
362
+ export interface HavingNumericOperator {
363
+ equals?: number;
364
+ not?: number;
365
+ gt?: number;
366
+ gte?: number;
367
+ lt?: number;
368
+ lte?: number;
369
+ in?: number[];
370
+ notIn?: number[];
371
+ }
372
+ /** A single having predicate value: a bare number (equality) or an operator object. */
373
+ export type HavingFilter = number | HavingNumericOperator;
374
+ /**
375
+ * Per-field aggregate filters inside a {@link HavingClause}. Each aggregate
376
+ * function maps to a {@link HavingFilter} comparison on that field.
377
+ *
378
+ * @example
379
+ * viewCount: { _sum: { gt: 100 }, _avg: { lte: 50 } }
380
+ */
381
+ export interface HavingAggregateFilter {
382
+ _sum?: HavingFilter;
383
+ _avg?: HavingFilter;
384
+ _min?: HavingFilter;
385
+ _max?: HavingFilter;
386
+ _count?: HavingFilter;
387
+ }
388
+ /**
389
+ * HAVING clause for `groupBy` — filters whole groups by their aggregate values
390
+ * (the SQL `HAVING` clause). Follows Prisma's shape: each aggregable field maps
391
+ * to a {@link HavingAggregateFilter} (`field → aggregate → operator → value`),
392
+ * and the special top-level `_count` key (no field) filters on `COUNT(*)`.
393
+ *
394
+ * Implemented as a mapped type so the special `_count` key can carry a
395
+ * {@link HavingFilter} while every entity field carries a
396
+ * {@link HavingAggregateFilter} — without the index-signature conflict an
397
+ * intersection type would produce when `T` is a broad `Record<string, unknown>`.
398
+ *
399
+ * @example
400
+ * // groups with more than 5 rows whose summed viewCount is at least 100
401
+ * having: { _count: { gt: 5 }, viewCount: { _sum: { gte: 100 } } }
402
+ */
403
+ export type HavingClause<T> = {
404
+ /** Filter on `COUNT(*)` for the whole group. */
405
+ _count?: HavingFilter;
406
+ } & {
407
+ [K in keyof T & string]?: HavingAggregateFilter;
408
+ };
355
409
  export interface GroupByArgs<T> {
356
410
  by: (keyof T & string)[];
357
411
  where?: WhereClause<T>;
@@ -365,6 +419,8 @@ export interface GroupByArgs<T> {
365
419
  _min?: Partial<Record<keyof T & string, boolean>>;
366
420
  /** Maximum value of fields in each group */
367
421
  _max?: Partial<Record<keyof T & string, boolean>>;
422
+ /** Filter whole groups by their aggregate values (SQL HAVING). */
423
+ having?: HavingClause<T>;
368
424
  /** Order groups */
369
425
  orderBy?: Record<string, OrderDirection>;
370
426
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
@@ -429,5 +485,73 @@ export interface TextSearchFilter {
429
485
  /** PostgreSQL text search configuration name (defaults to 'english') */
430
486
  config?: string;
431
487
  }
488
+ /**
489
+ * Vector distance metric. Maps to a pgvector distance operator:
490
+ *
491
+ * - `'l2'` → `<->` (Euclidean / L2 distance)
492
+ * - `'cosine'` → `<=>` (cosine distance)
493
+ * - `'ip'` → `<#>` (negative inner product)
494
+ *
495
+ * This is a fixed allow-list — a value outside it is rejected with a
496
+ * `ValidationError` so a user-supplied string can never become a SQL operator.
497
+ */
498
+ export type VectorMetric = 'l2' | 'cosine' | 'ip';
499
+ /**
500
+ * Distance threshold filter for a pgvector column inside a `where` clause:
501
+ *
502
+ * ```ts
503
+ * where: { embedding: { distance: { to: [0.1, 0.2, 0.3], metric: 'l2', lt: 0.3 } } }
504
+ * // → WHERE "embedding" <-> $1::vector < $2
505
+ * ```
506
+ *
507
+ * `to` is always bound as a single `$n::vector` param (never interpolated) and
508
+ * each element must be a finite number. Exactly one comparison
509
+ * (`lt` / `lte` / `gt` / `gte`) is applied; the threshold is also a bound param.
510
+ */
511
+ export interface VectorDistanceFilter {
512
+ /** The query vector to measure distance against. */
513
+ to: number[];
514
+ /** Distance metric → operator. */
515
+ metric: VectorMetric;
516
+ /** distance < threshold */
517
+ lt?: number;
518
+ /** distance <= threshold */
519
+ lte?: number;
520
+ /** distance > threshold */
521
+ gt?: number;
522
+ /** distance >= threshold */
523
+ gte?: number;
524
+ }
525
+ /** Vector query operators for where clauses (pgvector). */
526
+ export interface VectorFilter {
527
+ /** Filter rows by distance from a query vector. */
528
+ distance: VectorDistanceFilter;
529
+ }
530
+ /**
531
+ * KNN ordering spec for a pgvector column inside an `orderBy` clause:
532
+ *
533
+ * ```ts
534
+ * orderBy: { embedding: { distance: { to: [...], metric: 'cosine' } } }
535
+ * // → ORDER BY "embedding" <=> $1::vector ASC
536
+ * ```
537
+ *
538
+ * `distance` ASC ranks nearest-first (the default). Pass `direction: 'desc'`
539
+ * to invert. The query vector `to` is bound as a `$n::vector` param.
540
+ */
541
+ export interface VectorOrderByDistance {
542
+ to: number[];
543
+ metric: VectorMetric;
544
+ /** Sort direction for the computed distance. Defaults to `'asc'` (nearest first). */
545
+ direction?: OrderDirection;
546
+ }
547
+ /** Per-column orderBy value: a plain direction or a vector-distance ordering. */
548
+ export interface VectorOrderBy {
549
+ distance: VectorOrderByDistance;
550
+ }
551
+ /**
552
+ * An orderBy clause maps each column to either a plain direction (`'asc'` /
553
+ * `'desc'`) or, for pgvector columns, a KNN distance ordering object.
554
+ */
555
+ export type OrderByClause = Record<string, OrderDirection | VectorOrderBy>;
432
556
  export {};
433
557
  //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * turbine-orm — LISTEN/NOTIFY realtime pub/sub
3
+ *
4
+ * Postgres LISTEN/NOTIFY is a first-class realtime primitive that neither
5
+ * Prisma nor Drizzle expose ergonomically. This module backs the thin
6
+ * `$listen` / `$notify` methods on TurbineClient.
7
+ *
8
+ * Design — **one dedicated connection per subscription**:
9
+ *
10
+ * Each `$listen(channel, handler)` acquires its OWN long-lived client from
11
+ * the pool, runs `LISTEN "chan"`, and keeps that connection checked out for
12
+ * the life of the subscription. This is the simplest correct model: each
13
+ * subscription owns its lifecycle, `unsubscribe()` cleanly UNLISTENs and
14
+ * releases exactly one connection, and there is no shared multiplexing
15
+ * state to reason about. The trade-off is one pool slot per active channel
16
+ * — for the handful of channels a typical app listens on, that's a fine
17
+ * price for clarity. (A future optimization could multiplex many channels
18
+ * over a single shared notification connection.)
19
+ *
20
+ * Serverless / HTTP-pool caveat:
21
+ *
22
+ * LISTEN requires a *persistent* TCP connection that can push asynchronous
23
+ * notification messages back to the client. Stateless HTTP drivers
24
+ * (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
25
+ * `$listen` will surface a clear error rather than hang. `$notify` works
26
+ * everywhere — it's a single round-trip `SELECT pg_notify(...)`.
27
+ */
28
+ import type { PgCompatPool } from './client.js';
29
+ /**
30
+ * Validate a LISTEN/NOTIFY channel name. Throws ValidationError on anything
31
+ * that isn't a plain, reasonable-length SQL identifier. This is enforced for
32
+ * BOTH `$listen` (where the channel is interpolated) and `$notify` (where the
33
+ * channel is a bound param) — defensive parity, and it catches user typos
34
+ * loudly.
35
+ */
36
+ export declare function validateChannel(channel: string): void;
37
+ /** Handler invoked with the raw NOTIFY payload string (empty string if none). */
38
+ export type NotificationHandler = (payload: string) => void;
39
+ /**
40
+ * A live LISTEN subscription. Call `unsubscribe()` to UNLISTEN, detach the
41
+ * handler, and release the dedicated connection back to the pool.
42
+ */
43
+ export interface Subscription {
44
+ /** The channel this subscription is listening on. */
45
+ readonly channel: string;
46
+ /**
47
+ * Stop listening: runs `UNLISTEN "chan"`, removes the notification listener,
48
+ * and releases the dedicated connection. Idempotent — safe to call twice.
49
+ */
50
+ unsubscribe(): Promise<void>;
51
+ }
52
+ /**
53
+ * Internal registry handle so TurbineClient can track and tear down active
54
+ * subscriptions on `disconnect()`.
55
+ */
56
+ export interface ActiveSubscription extends Subscription {
57
+ /** Tear down WITHOUT issuing UNLISTEN (used when the pool is being ended). */
58
+ _forceRelease(): void;
59
+ }
60
+ /**
61
+ * Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
62
+ *
63
+ * @param pool the pg-compatible pool to check a long-lived client out of
64
+ * @param channel channel name — MUST already be validated by the caller
65
+ * @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
66
+ * @param handler called with each notification's payload
67
+ * @param onClosed invoked when the subscription releases, so the client can
68
+ * drop it from its active-subscription registry
69
+ */
70
+ export declare function createSubscription(pool: PgCompatPool, channel: string, quotedChannel: string, handler: NotificationHandler, onClosed: (sub: ActiveSubscription) => void): Promise<ActiveSubscription>;
71
+ //# sourceMappingURL=realtime.d.ts.map
@@ -0,0 +1,144 @@
1
+ /**
2
+ * turbine-orm — LISTEN/NOTIFY realtime pub/sub
3
+ *
4
+ * Postgres LISTEN/NOTIFY is a first-class realtime primitive that neither
5
+ * Prisma nor Drizzle expose ergonomically. This module backs the thin
6
+ * `$listen` / `$notify` methods on TurbineClient.
7
+ *
8
+ * Design — **one dedicated connection per subscription**:
9
+ *
10
+ * Each `$listen(channel, handler)` acquires its OWN long-lived client from
11
+ * the pool, runs `LISTEN "chan"`, and keeps that connection checked out for
12
+ * the life of the subscription. This is the simplest correct model: each
13
+ * subscription owns its lifecycle, `unsubscribe()` cleanly UNLISTENs and
14
+ * releases exactly one connection, and there is no shared multiplexing
15
+ * state to reason about. The trade-off is one pool slot per active channel
16
+ * — for the handful of channels a typical app listens on, that's a fine
17
+ * price for clarity. (A future optimization could multiplex many channels
18
+ * over a single shared notification connection.)
19
+ *
20
+ * Serverless / HTTP-pool caveat:
21
+ *
22
+ * LISTEN requires a *persistent* TCP connection that can push asynchronous
23
+ * notification messages back to the client. Stateless HTTP drivers
24
+ * (Neon HTTP, Vercel Postgres over fetch) cannot hold such a connection, so
25
+ * `$listen` will surface a clear error rather than hang. `$notify` works
26
+ * everywhere — it's a single round-trip `SELECT pg_notify(...)`.
27
+ */
28
+ import { ConnectionError, ValidationError, wrapPgError } from './errors.js';
29
+ // ---------------------------------------------------------------------------
30
+ // Identifier validation
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Strict Postgres identifier: a letter or underscore followed by letters,
34
+ * digits, or underscores. Channel names CANNOT be parameterized in
35
+ * LISTEN/UNLISTEN (`LISTEN $1` is a syntax error), so the channel is the one
36
+ * place an identifier is interpolated into SQL — it MUST pass this regex AND
37
+ * go through `quoteIdent` before reaching the SQL string.
38
+ */
39
+ const CHANNEL_REGEX = /^[A-Za-z_][A-Za-z0-9_]*$/;
40
+ /** Postgres NAMEDATALEN caps identifiers at 63 bytes. */
41
+ const MAX_CHANNEL_LEN = 63;
42
+ /**
43
+ * Validate a LISTEN/NOTIFY channel name. Throws ValidationError on anything
44
+ * that isn't a plain, reasonable-length SQL identifier. This is enforced for
45
+ * BOTH `$listen` (where the channel is interpolated) and `$notify` (where the
46
+ * channel is a bound param) — defensive parity, and it catches user typos
47
+ * loudly.
48
+ */
49
+ export function validateChannel(channel) {
50
+ if (typeof channel !== 'string' || channel.length === 0) {
51
+ throw new ValidationError('[turbine] $listen/$notify channel must be a non-empty string');
52
+ }
53
+ if (channel.length > MAX_CHANNEL_LEN) {
54
+ throw new ValidationError(`[turbine] $listen/$notify channel "${channel}" exceeds the ${MAX_CHANNEL_LEN}-character Postgres identifier limit`);
55
+ }
56
+ if (!CHANNEL_REGEX.test(channel)) {
57
+ throw new ValidationError(`[turbine] Invalid $listen/$notify channel "${channel}" — must match /^[A-Za-z_][A-Za-z0-9_]*$/ ` +
58
+ '(letters, digits, underscores; cannot start with a digit)');
59
+ }
60
+ }
61
+ /**
62
+ * Acquire a dedicated connection, run `LISTEN "channel"`, and wire the handler.
63
+ *
64
+ * @param pool the pg-compatible pool to check a long-lived client out of
65
+ * @param channel channel name — MUST already be validated by the caller
66
+ * @param quotedChannel the channel run through quoteIdent (interpolated into SQL)
67
+ * @param handler called with each notification's payload
68
+ * @param onClosed invoked when the subscription releases, so the client can
69
+ * drop it from its active-subscription registry
70
+ */
71
+ export async function createSubscription(pool, channel, quotedChannel, handler, onClosed) {
72
+ let client;
73
+ try {
74
+ client = (await pool.connect());
75
+ }
76
+ catch (err) {
77
+ throw wrapPgError(err);
78
+ }
79
+ // Verify the checked-out client can actually receive async notifications.
80
+ // Stateless HTTP drivers return a client with no `.on` — LISTEN would hang
81
+ // forever waiting for messages that can never arrive, so fail loudly now and
82
+ // give the connection straight back.
83
+ if (typeof client.on !== 'function') {
84
+ client.release?.();
85
+ throw new ConnectionError('[turbine] $listen requires a persistent connection that can push notifications. ' +
86
+ 'The configured pool returned a client with no event support (stateless HTTP drivers ' +
87
+ 'like Neon HTTP / Vercel Postgres cannot LISTEN). Use a TCP pg.Pool for LISTEN/NOTIFY.');
88
+ }
89
+ const onNotification = (msg) => {
90
+ // pg delivers ALL notifications for the connection to every listener; a
91
+ // dedicated connection only ever LISTENs on one channel, but guard anyway.
92
+ if (msg.channel === channel) {
93
+ handler(msg.payload ?? '');
94
+ }
95
+ };
96
+ try {
97
+ client.on('notification', onNotification);
98
+ await client.query(`LISTEN ${quotedChannel}`);
99
+ }
100
+ catch (err) {
101
+ client.removeListener?.('notification', onNotification);
102
+ client.release?.();
103
+ throw wrapPgError(err);
104
+ }
105
+ let closed = false;
106
+ const sub = {
107
+ channel,
108
+ async unsubscribe() {
109
+ if (closed)
110
+ return;
111
+ closed = true;
112
+ try {
113
+ await client.query(`UNLISTEN ${quotedChannel}`);
114
+ }
115
+ catch (err) {
116
+ // Best-effort: the connection may already be dead. Still detach +
117
+ // release below so we don't leak the pool slot.
118
+ client.removeListener?.('notification', onNotification);
119
+ client.release?.();
120
+ onClosed(sub);
121
+ throw wrapPgError(err);
122
+ }
123
+ client.removeListener?.('notification', onNotification);
124
+ client.release?.();
125
+ onClosed(sub);
126
+ },
127
+ _forceRelease() {
128
+ if (closed)
129
+ return;
130
+ closed = true;
131
+ client.removeListener?.('notification', onNotification);
132
+ // Destroy the connection (release(true)) rather than return it to the pool:
133
+ // we skip UNLISTEN here (the pool is being torn down), so a recycled
134
+ // connection would otherwise carry a stale LISTEN registration. Destroying
135
+ // it guarantees no pooled backend keeps receiving NOTIFY traffic. Matters
136
+ // most for external/serverless pools, where disconnect() is a no-op and the
137
+ // pool outlives this client.
138
+ client.release?.(true);
139
+ onClosed(sub);
140
+ },
141
+ };
142
+ return sub;
143
+ }
144
+ //# sourceMappingURL=realtime.js.map
@@ -22,6 +22,7 @@
22
22
  * });
23
23
  * ```
24
24
  */
25
+ import type { SchemaMetadata } from './schema.js';
25
26
  /** Shorthand type names that map to Postgres column types */
26
27
  export type ColumnTypeName = 'serial' | 'bigint' | 'integer' | 'smallint' | 'text' | 'varchar' | 'boolean' | 'timestamp' | 'date' | 'json' | 'uuid' | 'real' | 'double' | 'numeric' | 'bytea';
27
28
  /** Column definition as a plain object. This is what users write. */
@@ -55,6 +56,36 @@ export interface ColumnConfig {
55
56
  referencesTarget: string | null;
56
57
  maxLength: number | null;
57
58
  }
59
+ /**
60
+ * Explicit many-to-many relation declaration for the code-first schema.
61
+ *
62
+ * Auto-detecting m2m from a junction table is intentionally conservative (a
63
+ * junction with payload columns is treated as a first-class entity, not a join
64
+ * table — see `introspect.ts`). This declaration lets users opt in to an m2m
65
+ * relation explicitly, mirroring how Prisma/Drizzle require an explicit
66
+ * `@relation` / `relation()` for join tables.
67
+ *
68
+ * All names are the JS-facing accessor / camelCase field names you wrote in
69
+ * `defineSchema({ ... })`; they are normalized to snake_case when merged into
70
+ * the introspected {@link SchemaMetadata} via {@link applyManyToManyRelations}.
71
+ */
72
+ export interface ManyToManyDef {
73
+ /** Relation field name on the source table (e.g. `tags`). */
74
+ name: string;
75
+ /** Target table accessor (e.g. `tags`). */
76
+ target: string;
77
+ /** Junction (join) table accessor (e.g. `postsTags`). */
78
+ through: string;
79
+ /** Junction column(s) referencing the SOURCE table's PK. */
80
+ sourceKey: string | readonly string[];
81
+ /** Junction column(s) referencing the TARGET table's PK. */
82
+ targetKey: string | readonly string[];
83
+ /**
84
+ * Optional: the SOURCE table's referenced column(s) that `sourceKey` points
85
+ * at. Defaults to `id`. Use for sources keyed on a non-`id` / composite PK.
86
+ */
87
+ references?: string | readonly string[];
88
+ }
58
89
  export interface TableDef {
59
90
  /**
60
91
  * DDL-facing table name (snake_case). This is the name used when generating
@@ -79,6 +110,13 @@ export interface TableDef {
79
110
  * when emitted as a `PRIMARY KEY (...)` table constraint.
80
111
  */
81
112
  primaryKey?: readonly string[];
113
+ /**
114
+ * Explicit many-to-many relations declared on this table. These never affect
115
+ * DDL emission (junction tables are still ordinary `CREATE TABLE`s); they are
116
+ * consumed by {@link applyManyToManyRelations} to enrich an introspected
117
+ * {@link SchemaMetadata} with `manyToMany` {@link RelationDef}s.
118
+ */
119
+ manyToMany?: readonly ManyToManyDef[];
82
120
  }
83
121
  /**
84
122
  * User-facing input shape for a single table when using the object format.
@@ -87,8 +125,10 @@ export interface TableDef {
87
125
  export interface TableInput {
88
126
  /** Optional composite primary key (camelCase field names) */
89
127
  primaryKey?: readonly string[];
128
+ /** Optional explicit many-to-many relations on this table */
129
+ manyToMany?: readonly ManyToManyDef[];
90
130
  /** Column definitions keyed by camelCase field name */
91
- [columnName: string]: ColumnDef | readonly string[] | undefined;
131
+ [columnName: string]: ColumnDef | readonly string[] | readonly ManyToManyDef[] | undefined;
92
132
  }
93
133
  export interface SchemaDef {
94
134
  /**
@@ -156,5 +196,32 @@ type ColumnProxy = {
156
196
  export declare const column: ColumnProxy;
157
197
  /** @deprecated Use defineSchema() with plain objects instead */
158
198
  export declare function table(columns: Record<string, ColumnBuilder>): TableDef;
199
+ /**
200
+ * Merge the explicit `manyToMany` declarations from a code-first {@link SchemaDef}
201
+ * into an introspected {@link SchemaMetadata}, returning a new metadata object
202
+ * with the `manyToMany` {@link RelationDef}s added.
203
+ *
204
+ * This is the runtime bridge for the code-first m2m API: `defineSchema` only
205
+ * produces DDL, so after `introspect()`ing the live database you call this to
206
+ * attach the m2m relations you declared. It is PURELY ADDITIVE — existing
207
+ * belongsTo/hasMany/hasOne relations are preserved, and a declared relation is
208
+ * skipped (not overwritten) if its name already exists on the source table.
209
+ *
210
+ * @example
211
+ * ```ts
212
+ * const def = defineSchema({
213
+ * posts: { id: { type: 'serial', primaryKey: true },
214
+ * manyToMany: [{ name: 'tags', target: 'tags', through: 'postsTags',
215
+ * sourceKey: 'postId', targetKey: 'tagId' }] },
216
+ * tags: { id: { type: 'serial', primaryKey: true } },
217
+ * postsTags: { postId: { type: 'integer', references: 'posts.id' },
218
+ * tagId: { type: 'integer', references: 'tags.id' },
219
+ * primaryKey: ['postId', 'tagId'] },
220
+ * });
221
+ * let meta = await introspect({ connectionString });
222
+ * meta = applyManyToManyRelations(meta, def);
223
+ * ```
224
+ */
225
+ export declare function applyManyToManyRelations(meta: SchemaMetadata, def: SchemaDef): SchemaMetadata;
159
226
  export { camelToSnake } from './schema.js';
160
227
  //# sourceMappingURL=schema-builder.d.ts.map
@@ -97,7 +97,17 @@ export function defineSchema(input) {
97
97
  const raw = value;
98
98
  const columns = {};
99
99
  let pk;
100
+ let m2m;
100
101
  for (const [fieldName, def] of Object.entries(raw)) {
102
+ if (fieldName === 'manyToMany') {
103
+ if (def !== undefined) {
104
+ if (!Array.isArray(def)) {
105
+ throw new Error(`Table "${accessor}": "manyToMany" must be an array of relation declarations`);
106
+ }
107
+ m2m = def;
108
+ }
109
+ continue;
110
+ }
101
111
  if (fieldName === 'primaryKey') {
102
112
  // Top-level composite primary key declaration
103
113
  if (def !== undefined) {
@@ -137,6 +147,7 @@ export function defineSchema(input) {
137
147
  accessor,
138
148
  columns,
139
149
  ...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
150
+ ...(m2m && m2m.length > 0 ? { manyToMany: m2m } : {}),
140
151
  };
141
152
  }
142
153
  }
@@ -297,6 +308,80 @@ export function table(columns) {
297
308
  return { name: '', accessor: '', columns: built };
298
309
  }
299
310
  // ---------------------------------------------------------------------------
311
+ // Explicit many-to-many: merge declared relations into introspected metadata
312
+ // ---------------------------------------------------------------------------
313
+ /**
314
+ * Merge the explicit `manyToMany` declarations from a code-first {@link SchemaDef}
315
+ * into an introspected {@link SchemaMetadata}, returning a new metadata object
316
+ * with the `manyToMany` {@link RelationDef}s added.
317
+ *
318
+ * This is the runtime bridge for the code-first m2m API: `defineSchema` only
319
+ * produces DDL, so after `introspect()`ing the live database you call this to
320
+ * attach the m2m relations you declared. It is PURELY ADDITIVE — existing
321
+ * belongsTo/hasMany/hasOne relations are preserved, and a declared relation is
322
+ * skipped (not overwritten) if its name already exists on the source table.
323
+ *
324
+ * @example
325
+ * ```ts
326
+ * const def = defineSchema({
327
+ * posts: { id: { type: 'serial', primaryKey: true },
328
+ * manyToMany: [{ name: 'tags', target: 'tags', through: 'postsTags',
329
+ * sourceKey: 'postId', targetKey: 'tagId' }] },
330
+ * tags: { id: { type: 'serial', primaryKey: true } },
331
+ * postsTags: { postId: { type: 'integer', references: 'posts.id' },
332
+ * tagId: { type: 'integer', references: 'tags.id' },
333
+ * primaryKey: ['postId', 'tagId'] },
334
+ * });
335
+ * let meta = await introspect({ connectionString });
336
+ * meta = applyManyToManyRelations(meta, def);
337
+ * ```
338
+ */
339
+ export function applyManyToManyRelations(meta, def) {
340
+ // Map accessor (camelCase key) → DDL snake_case table name.
341
+ const accessorToTable = new Map();
342
+ for (const [accessor, t] of Object.entries(def.tables)) {
343
+ accessorToTable.set(accessor, t.name);
344
+ }
345
+ const resolveTable = (accessor) => accessorToTable.get(accessor) ?? camelToSnakeLocal(accessor);
346
+ const resolveCols = (k) => {
347
+ if (Array.isArray(k))
348
+ return k.map(camelToSnakeLocal);
349
+ return camelToSnakeLocal(k);
350
+ };
351
+ // Deep-ish clone of the tables we touch so the input metadata is not mutated.
352
+ const tables = { ...meta.tables };
353
+ for (const tableDef of Object.values(def.tables)) {
354
+ if (!tableDef.manyToMany || tableDef.manyToMany.length === 0)
355
+ continue;
356
+ const sourceTable = tableDef.name;
357
+ const sourceMeta = tables[sourceTable];
358
+ if (!sourceMeta)
359
+ continue; // table not present in introspected metadata — skip
360
+ const relations = { ...sourceMeta.relations };
361
+ for (const m of tableDef.manyToMany) {
362
+ // Additive-only: never clobber an existing relation name.
363
+ if (relations[m.name])
364
+ continue;
365
+ const ref = m.references ?? 'id';
366
+ relations[m.name] = {
367
+ type: 'manyToMany',
368
+ name: m.name,
369
+ from: sourceTable,
370
+ to: resolveTable(m.target),
371
+ referenceKey: resolveCols(ref),
372
+ foreignKey: resolveCols(ref),
373
+ through: {
374
+ table: resolveTable(m.through),
375
+ sourceKey: resolveCols(m.sourceKey),
376
+ targetKey: resolveCols(m.targetKey),
377
+ },
378
+ };
379
+ }
380
+ tables[sourceTable] = { ...sourceMeta, relations };
381
+ }
382
+ return { ...meta, tables };
383
+ }
384
+ // ---------------------------------------------------------------------------
300
385
  // Helpers
301
386
  // ---------------------------------------------------------------------------
302
387
  export { camelToSnake } from './schema.js';
package/dist/schema.d.ts CHANGED
@@ -61,7 +61,7 @@ export interface ColumnMetadata {
61
61
  maxLength?: number;
62
62
  }
63
63
  export interface RelationDef {
64
- type: 'hasMany' | 'hasOne' | 'belongsTo';
64
+ type: 'hasMany' | 'hasOne' | 'belongsTo' | 'manyToMany';
65
65
  /** Relation name (camelCase, used as the field name) */
66
66
  name: string;
67
67
  /** Source table */
@@ -72,6 +72,23 @@ export interface RelationDef {
72
72
  foreignKey: string | string[];
73
73
  /** Referenced column(s) on the "one" / "parent" side (snake_case). Array for composite FKs. */
74
74
  referenceKey: string | string[];
75
+ /**
76
+ * For `manyToMany` relations only: the junction (join) table that links the
77
+ * source and target tables. The subquery JOINs the target through this table.
78
+ *
79
+ * - `table` — junction table name (snake_case).
80
+ * - `sourceKey` — junction column(s) referencing the SOURCE table's
81
+ * {@link referenceKey} (typically the source PK).
82
+ * - `targetKey` — junction column(s) referencing the TARGET table's PK.
83
+ *
84
+ * Array forms support composite keys (paired positionally with the
85
+ * referenced columns). Omitted for non-m2m relations.
86
+ */
87
+ through?: {
88
+ table: string;
89
+ sourceKey: string | string[];
90
+ targetKey: string | string[];
91
+ };
75
92
  }
76
93
  /** Normalize foreignKey/referenceKey to always be an array for uniform processing */
77
94
  export declare function normalizeKeyColumns(key: string | string[]): string[];
package/dist/schema.js CHANGED
@@ -66,6 +66,16 @@ const PG_TO_TS = {
66
66
  // TSVector
67
67
  tsvector: 'string',
68
68
  tsquery: 'string',
69
+ // pgvector — embeddings. Mapped to `number[]` for DX (the natural shape an app
70
+ // passes when inserting / comparing embeddings). NOTE: like `numeric` above,
71
+ // there is a runtime caveat — pg has no built-in parser for the `vector` type,
72
+ // so over the wire a fetched vector arrives as a string literal like
73
+ // '[1,2,3]' unless the app registers its own parser (e.g. via pgvector's
74
+ // `registerType`). Turbine never auto-registers one (no side-effecting type
75
+ // parsers outside the client constructor). The query-side helpers (KNN
76
+ // orderBy, distance WHERE) always bind the query vector as a `$n::vector`
77
+ // param, so writing/comparing is unaffected by the read-side caveat.
78
+ vector: 'number[]',
69
79
  };
70
80
  const DATE_TYPES = new Set(['timestamptz', 'timestamp', 'date']);
71
81
  const PG_TO_ARRAY = {