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.
- package/README.md +180 -12
- package/dist/adapters/cockroachdb.js +4 -2
- package/dist/adapters/index.js +4 -1
- package/dist/adapters/yugabytedb.js +4 -2
- package/dist/cjs/adapters/cockroachdb.js +4 -2
- package/dist/cjs/adapters/index.js +4 -1
- package/dist/cjs/adapters/yugabytedb.js +4 -2
- package/dist/cjs/cli/studio-ui.generated.js +1 -1
- package/dist/cjs/cli/studio.js +35 -73
- package/dist/cjs/client.js +164 -0
- package/dist/cjs/errors.js +35 -5
- package/dist/cjs/generate.js +14 -3
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/introspect.js +81 -0
- package/dist/cjs/nested-write.js +70 -6
- package/dist/cjs/query/builder.js +581 -17
- package/dist/cjs/realtime.js +147 -0
- package/dist/cjs/schema-builder.js +86 -0
- package/dist/cjs/schema.js +10 -0
- package/dist/cjs/typed-sql.js +149 -0
- package/dist/cli/studio-ui.generated.js +1 -1
- package/dist/cli/studio.js +35 -73
- package/dist/client.d.ts +120 -0
- package/dist/client.js +165 -1
- package/dist/errors.js +35 -5
- package/dist/generate.js +14 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +5 -1
- package/dist/introspect.js +81 -0
- package/dist/nested-write.js +70 -6
- package/dist/query/builder.d.ts +104 -1
- package/dist/query/builder.js +582 -18
- package/dist/query/index.d.ts +1 -1
- package/dist/query/types.d.ts +126 -2
- package/dist/realtime.d.ts +71 -0
- package/dist/realtime.js +144 -0
- package/dist/schema-builder.d.ts +68 -1
- package/dist/schema-builder.js +85 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.js +10 -0
- package/dist/typed-sql.d.ts +101 -0
- package/dist/typed-sql.js +145 -0
- package/package.json +17 -15
package/dist/query/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/query/types.d.ts
CHANGED
|
@@ -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?:
|
|
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
|
package/dist/realtime.js
ADDED
|
@@ -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
|
package/dist/schema-builder.d.ts
CHANGED
|
@@ -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
|
package/dist/schema-builder.js
CHANGED
|
@@ -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 = {
|