@absolutejs/sync 2.2.2 → 2.3.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 +89 -32
- package/dist/connectionBroker.d.ts +147 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +322 -55
- package/dist/index.js.map +5 -4
- package/dist/manifest.js +2 -4
- package/dist/manifest.js.map +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -361,18 +361,75 @@ This is what `@absolutejs/voice` uses to keep its per-audio-frame session state
|
|
|
361
361
|
memory while the Drizzle/Postgres store stays the durable source of truth — without
|
|
362
362
|
it, ~3 store round-trips every 20ms ran the voice pipeline far slower than real time.
|
|
363
363
|
|
|
364
|
+
## Connection broker — one upstream pool, many tenants
|
|
365
|
+
|
|
366
|
+
BYO-Postgres multi-tenancy has a sharp edge: one shard hosting 50 customers, each
|
|
367
|
+
spawning its own PG pool, instantly exceeds a managed provider's connection limit
|
|
368
|
+
(Supabase, Neon, RDS all cap you long before 50 × pool-size). `createConnectionBroker`
|
|
369
|
+
multiplexes ONE upstream connection budget across every tenant on the shard — a
|
|
370
|
+
global in-use cap, optional per-tenant budgets, FIFO queueing with timeouts when at
|
|
371
|
+
cap, and idle harvesting so a burst doesn't pin connections forever. No pgbouncer
|
|
372
|
+
sidecar to run.
|
|
373
|
+
|
|
374
|
+
The broker never imports a DB driver — `create`/`destroy` are yours, so it wraps
|
|
375
|
+
postgres-js, `Bun.sql`, or anything that opens a connection:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
import postgres from 'postgres';
|
|
379
|
+
import { createConnectionBroker } from '@absolutejs/sync';
|
|
380
|
+
|
|
381
|
+
const broker = createConnectionBroker({
|
|
382
|
+
create: () => postgres(process.env.DATABASE_URL!, { max: 1 }),
|
|
383
|
+
destroy: (sql) => sql.end(),
|
|
384
|
+
maxTotal: 20, // the whole shard shares 20 upstream connections
|
|
385
|
+
maxPerTenant: 3, // one noisy tenant queues against its own budget
|
|
386
|
+
idleReleaseMs: 30_000, // harvest idle connections after a burst
|
|
387
|
+
acquireTimeoutMs: 5_000, // reject (LeaseTimeoutError) instead of hanging
|
|
388
|
+
validate: (sql) => sql`select 1`.then(() => true).catch(() => false)
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Per-tenant work leases from the shared budget:
|
|
392
|
+
const rows = await broker.withLease(
|
|
393
|
+
tenantId,
|
|
394
|
+
(sql) => sql`select * from orders where tenant = ${tenantId}`
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// Or hold a lease across several statements:
|
|
398
|
+
const { conn, release } = await broker.lease(tenantId);
|
|
399
|
+
try {
|
|
400
|
+
/* ... */
|
|
401
|
+
} finally {
|
|
402
|
+
release(); // idempotent; hands the connection to the next queued waiter
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Shutdown: stop new leases, wait for outstanding work, close idle connections.
|
|
406
|
+
await broker.drain();
|
|
407
|
+
await broker.dispose();
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Released connections return to a LIFO idle pool (the warmest connection is reused
|
|
411
|
+
first) and are handed to queued waiters immediately. `broker.metrics()` returns an
|
|
412
|
+
operator-shaped snapshot (`inUse`, `idle`, `queued`, `byTenant`, cumulative
|
|
413
|
+
lease/timeout/create/destroy counters), and a `tracerProvider` traces every lease as
|
|
414
|
+
a `sync.broker_lease` span carrying `abs.tenant` and the queue wait in milliseconds.
|
|
415
|
+
|
|
416
|
+
This pairs with the tenant-per-engine hosting pattern: each tenant's writers and
|
|
417
|
+
readers call `broker.withLease(tenant, ...)` instead of owning a pool, so adding a
|
|
418
|
+
tenant to a shard adds zero standing connections.
|
|
419
|
+
|
|
364
420
|
## API
|
|
365
421
|
|
|
366
422
|
### `@absolutejs/sync`
|
|
367
423
|
|
|
368
|
-
| Export
|
|
369
|
-
|
|
|
370
|
-
| `createReactiveHub()`
|
|
371
|
-
| `sync({ hub, path?, resolveTopics?, heartbeatMs? })`
|
|
372
|
-
| `syncSocket({ engine, path?, resolveContext? })`
|
|
373
|
-
| `scheduled({ engine, prefix?, onError? })` _(`/scheduled` subpath)_
|
|
374
|
-
| `syncDevtools({ engine, path?, snapshotMs? })`
|
|
375
|
-
| `createWriteBehindCache({ load, persist, remove?, debounceMs?, evict?, onPersistError? })`
|
|
424
|
+
| Export | What it is |
|
|
425
|
+
| ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
426
|
+
| `createReactiveHub()` | In-memory topic pub/sub (`publish`, `subscribe`, `subscriberCount`). |
|
|
427
|
+
| `sync({ hub, path?, resolveTopics?, heartbeatMs? })` | Elysia plugin: SSE stream of hub events. |
|
|
428
|
+
| `syncSocket({ engine, path?, resolveContext? })` | Elysia WebSocket plugin for the sync engine. |
|
|
429
|
+
| `scheduled({ engine, prefix?, onError? })` _(`/scheduled` subpath)_ | Elysia plugin: fires the engine's registered schedules on their cron patterns (via `@elysiajs/cron`). Kept off the main entry so `syncSocket` needs no cron dep. |
|
|
430
|
+
| `syncDevtools({ engine, path?, snapshotMs? })` | Elysia plugin: a live devtools dashboard (collections, subscription counts, mutations, schedules, change feed) over SSE. Backed by `engine.inspect()` + `engine.onActivity()`. **1.23** also exposes a Point-in-time replay panel (datetime picker + tables filter) and a `GET <path>/replay?at=<ms>&tables=<csv>` JSON endpoint wrapping `engine.replayTo`. |
|
|
431
|
+
| `createWriteBehindCache({ load, persist, remove?, debounceMs?, evict?, onPersistError? })` | In-memory cache + write-behind persistence. |
|
|
432
|
+
| `createConnectionBroker({ create, maxTotal, maxPerTenant?, idleReleaseMs?, acquireTimeoutMs?, validate?, destroy?, onError?, now?, tracerProvider? })` | Multiplex one upstream connection budget across many tenants: `lease`/`withLease`/`metrics`/`drain`/`dispose`. Throws `LeaseTimeoutError` / `ConnectionBrokerDrainedError` (both exported). |
|
|
376
433
|
|
|
377
434
|
### `@absolutejs/sync/client`
|
|
378
435
|
|
|
@@ -423,31 +480,31 @@ mutate({
|
|
|
423
480
|
|
|
424
481
|
### `@absolutejs/sync/engine`
|
|
425
482
|
|
|
426
|
-
| Export | What it is
|
|
427
|
-
| ---------------------------------------------------------------------------------------- |
|
|
428
|
-
| `createSyncEngine()` | Registry + view syncer: `register`, `subscribe`, `applyChange`, `connectSource`, `registerMutation`, `registerWriter`, `runMutation`.
|
|
429
|
-
| `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })` | Define a syncable collection.
|
|
430
|
-
| `defineMutation({ name, handler, authorize? })` | Define a server mutation. Its `handler` gets `actions.insert/update/delete` (write through a registered `TableWriter` → persists + emits in one step) plus `actions.change` (escape hatch). Changes commit atomically.
|
|
431
|
-
| `registerWriter(table, { insert, update, delete })` | Teach the engine how to persist a table (any ORM), so writes auto-emit — you can't write without going live.
|
|
432
|
-
| `createAggregate({ key, groupBy?, value? })` | Incremental count/sum/avg/min/max by group.
|
|
433
|
-
| `createMaterializedView({ key, match, equals? })` | The predicate-matching IVM primitive (`apply`/`reset` → diffs).
|
|
434
|
-
| `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })` | DB-agnostic CDC `ChangeSource` that tails a changelog (outbox) table.
|
|
435
|
-
| `engine.connectCluster(bus)` + `createInMemoryClusterBus()` | Horizontal scale: fan changes across server instances over a `ClusterBus` (BYO Redis/Postgres; in-memory bus for dev).
|
|
436
|
-
| `createPresenceHub()` + `syncSocket({ engine, presence })` | Ephemeral room-scoped presence (online / typing / cursors) over the same socket — not persisted, auto-cleaned on disconnect.
|
|
437
|
-
| `query(source).filter().map().join().leftJoin().groupBy().orderBy()` | Declarative incremental query builder (the operator graph).
|
|
438
|
-
| `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection.
|
|
439
|
-
| `defineReactiveQuery({ name, run, key })` + `registerReactive` / `registerReader` | Read-set-tracked query: `run(ctx)` reads via `ctx.db` (`all`/`get`/`where`) and re-runs only when the rows/ranges it read change — no `match`, no manual emit.
|
|
440
|
-
| `definePermissions({ [table]: { read?, insert?, update?, delete?, write? } })` | Declarative row-level access control. Pass as `createSyncEngine({ permissions })` or `registerPermissions(table, rules)`. Read rules filter every row emitted; write rules gate `actions.insert/update/delete`.
|
|
441
|
-
| `defineSchema({ [table]: { fields, version?, migrate? } })` + `field` kit | Declarative row schema. Pass as `createSyncEngine({ schemas })` or `registerSchema(table, schema)`. Writes are validated (bad write → `SchemaError`); `migrate` lazily upcasts rows on read (no DB migration needed).
|
|
442
|
-
| `registerCrdt(table, { [field]: mergeable })` | Declare CRDT fields (a `CrdtMergeable` like `rgaText`, or `yjsText` from `@absolutejs/sync-yjs`). The engine **merges** those fields on `actions.insert/update` instead of overwriting — conflict-free collaborative editing with no merge code — and auto-registers a `"<table>:merge"` mutation the `useCollaborativeText` hooks call.
|
|
443
|
-
| `defineSearchCollection({ name, table, index, source, key, limit? })` + `registerSearch` | Live search collection: the subscription's `params` are the query (string/vector), the ranked top-K stream back as a normal collection, re-ranked as rows change. Each row carries its score under `_score`.
|
|
444
|
-
| `createTextIndex({ key, fields, tokenize?, stopwords?, k1?, b? })` | Incremental BM25 full-text index (keyword search). Implements `SearchIndex`; usable standalone or inside a search collection.
|
|
445
|
-
| `createVectorIndex({ key, embedding, metric? })` | Incremental vector index (cosine/dot/euclidean exact k-NN) for semantic search — pairs with `@absolutejs/ai` / `@absolutejs/rag` for RAG retrieval on your own data.
|
|
446
|
-
| `defineSchedule({ name, pattern, run })` + `registerSchedule` / `runSchedule` | Scheduled function: `run({ db, actions })` fires on a cron `pattern`; its writes go live through the change feed. Wire triggers with the `scheduled` plugin (or call `runSchedule(name)` on demand).
|
|
483
|
+
| Export | What it is |
|
|
484
|
+
| ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
485
|
+
| `createSyncEngine()` | Registry + view syncer: `register`, `subscribe`, `applyChange`, `connectSource`, `registerMutation`, `registerWriter`, `runMutation`. |
|
|
486
|
+
| `defineCollection({ name, hydrate, key?, match?, authorize?, tables? })` | Define a syncable collection. |
|
|
487
|
+
| `defineMutation({ name, handler, authorize? })` | Define a server mutation. Its `handler` gets `actions.insert/update/delete` (write through a registered `TableWriter` → persists + emits in one step) plus `actions.change` (escape hatch). Changes commit atomically. |
|
|
488
|
+
| `registerWriter(table, { insert, update, delete })` | Teach the engine how to persist a table (any ORM), so writes auto-emit — you can't write without going live. |
|
|
489
|
+
| `createAggregate({ key, groupBy?, value? })` | Incremental count/sum/avg/min/max by group. |
|
|
490
|
+
| `createMaterializedView({ key, match, equals? })` | The predicate-matching IVM primitive (`apply`/`reset` → diffs). |
|
|
491
|
+
| `createPollingChangeSource({ poll, intervalMs?, startSeq?, onProcessed? })` | DB-agnostic CDC `ChangeSource` that tails a changelog (outbox) table. |
|
|
492
|
+
| `engine.connectCluster(bus)` + `createInMemoryClusterBus()` | Horizontal scale: fan changes across server instances over a `ClusterBus` (BYO Redis/Postgres; in-memory bus for dev). |
|
|
493
|
+
| `createPresenceHub()` + `syncSocket({ engine, presence })` | Ephemeral room-scoped presence (online / typing / cursors) over the same socket — not persisted, auto-cleaned on disconnect. |
|
|
494
|
+
| `query(source).filter().map().join().leftJoin().groupBy().orderBy()` | Declarative incremental query builder (the operator graph). |
|
|
495
|
+
| `defineGraphCollection({ name, query, key, authorize? })` | Run a `query` as a live collection. |
|
|
496
|
+
| `defineReactiveQuery({ name, run, key })` + `registerReactive` / `registerReader` | Read-set-tracked query: `run(ctx)` reads via `ctx.db` (`all`/`get`/`where`) and re-runs only when the rows/ranges it read change — no `match`, no manual emit. |
|
|
497
|
+
| `definePermissions({ [table]: { read?, insert?, update?, delete?, write? } })` | Declarative row-level access control. Pass as `createSyncEngine({ permissions })` or `registerPermissions(table, rules)`. Read rules filter every row emitted; write rules gate `actions.insert/update/delete`. |
|
|
498
|
+
| `defineSchema({ [table]: { fields, version?, migrate? } })` + `field` kit | Declarative row schema. Pass as `createSyncEngine({ schemas })` or `registerSchema(table, schema)`. Writes are validated (bad write → `SchemaError`); `migrate` lazily upcasts rows on read (no DB migration needed). |
|
|
499
|
+
| `registerCrdt(table, { [field]: mergeable })` | Declare CRDT fields (a `CrdtMergeable` like `rgaText`, or `yjsText` from `@absolutejs/sync-yjs`). The engine **merges** those fields on `actions.insert/update` instead of overwriting — conflict-free collaborative editing with no merge code — and auto-registers a `"<table>:merge"` mutation the `useCollaborativeText` hooks call. |
|
|
500
|
+
| `defineSearchCollection({ name, table, index, source, key, limit? })` + `registerSearch` | Live search collection: the subscription's `params` are the query (string/vector), the ranked top-K stream back as a normal collection, re-ranked as rows change. Each row carries its score under `_score`. |
|
|
501
|
+
| `createTextIndex({ key, fields, tokenize?, stopwords?, k1?, b? })` | Incremental BM25 full-text index (keyword search). Implements `SearchIndex`; usable standalone or inside a search collection. |
|
|
502
|
+
| `createVectorIndex({ key, embedding, metric? })` | Incremental vector index (cosine/dot/euclidean exact k-NN) for semantic search — pairs with `@absolutejs/ai` / `@absolutejs/rag` for RAG retrieval on your own data. |
|
|
503
|
+
| `defineSchedule({ name, pattern, run })` + `registerSchedule` / `runSchedule` | Scheduled function: `run({ db, actions })` fires on a cron `pattern`; its writes go live through the change feed. Wire triggers with the `scheduled` plugin (or call `runSchedule(name)` on demand). |
|
|
447
504
|
| `engine.replayTo({ at, tables? })` _(1.22)_ | Walk the bounded change log forward to a target timestamp and return `{ asOfVersion, asOfAt, rows, truncated }`. Forensic incident response ("what did the tenant see at 14:32?") + restore-from-time ("revert to 2 hours ago"). `truncated: true` when the log doesn't extend back to `at` — widen `changeLogRetainMs` for forensic use cases. |
|
|
448
|
-
| `engine.fence({ reason })` _(1.24)_ | Pause new mutations on the engine — the source half of tenant migration. `runMutation` rejects with `EngineFencedError`; subscribe / hydrate / streamChanges stay open. Multiple fences compose; every handle must `lift()` before the engine unfences. `lift()` is idempotent.
|
|
449
|
-
| `engine.exportSnapshot({ tables?, ctx? })` _(1.24)_ | Walk every registered reader's `all(ctx)` and return a portable `EngineSnapshot { sourceInstanceId, version, exportedAt, tables }`. Detached from `ChangeLogSnapshot` — snapshots carry live state, not history.
|
|
450
|
-
| `engine.importSnapshot(snapshot, { tables?, onProgress?, ctx? })` _(1.24)_ | Bulk-load an `EngineSnapshot` on the target via each table's registered writer. Returns `{ tablesImported, rowsImported, perTable, skipped }`. Tables in the snapshot without a writer on the target surface in `skipped` so misconfigured targets don't silently drop rows.
|
|
505
|
+
| `engine.fence({ reason })` _(1.24)_ | Pause new mutations on the engine — the source half of tenant migration. `runMutation` rejects with `EngineFencedError`; subscribe / hydrate / streamChanges stay open. Multiple fences compose; every handle must `lift()` before the engine unfences. `lift()` is idempotent. |
|
|
506
|
+
| `engine.exportSnapshot({ tables?, ctx? })` _(1.24)_ | Walk every registered reader's `all(ctx)` and return a portable `EngineSnapshot { sourceInstanceId, version, exportedAt, tables }`. Detached from `ChangeLogSnapshot` — snapshots carry live state, not history. |
|
|
507
|
+
| `engine.importSnapshot(snapshot, { tables?, onProgress?, ctx? })` _(1.24)_ | Bulk-load an `EngineSnapshot` on the target via each table's registered writer. Returns `{ tablesImported, rowsImported, perTable, skipped }`. Tables in the snapshot without a writer on the target surface in `skipped` so misconfigured targets don't silently drop rows. |
|
|
451
508
|
|
|
452
509
|
### `@absolutejs/sync/crdt`
|
|
453
510
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { type TracerProvider as TelemetryTracerProvider } from '@absolutejs/telemetry';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by `broker.lease` when `acquireTimeoutMs` elapses before a
|
|
4
|
+
* connection frees up. The tenant and timeout carry through so operators
|
|
5
|
+
* can correlate timeouts to the tenant that starved.
|
|
6
|
+
*/
|
|
7
|
+
export declare class LeaseTimeoutError extends Error {
|
|
8
|
+
readonly tenant: string;
|
|
9
|
+
readonly timeoutMs: number;
|
|
10
|
+
constructor(tenant: string, timeoutMs: number);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Thrown by `broker.lease` after `drain()`/`dispose()` — the broker is
|
|
14
|
+
* winding down and no new leases are issued. Queued waiters that were
|
|
15
|
+
* still pending at `dispose()` are rejected with this too.
|
|
16
|
+
*/
|
|
17
|
+
export declare class ConnectionBrokerDrainedError extends Error {
|
|
18
|
+
constructor();
|
|
19
|
+
}
|
|
20
|
+
export type ConnectionBrokerOptions<Conn> = {
|
|
21
|
+
/**
|
|
22
|
+
* Open one upstream connection. Called lazily — only when a lease needs
|
|
23
|
+
* a connection and the idle pool is empty. The broker never imports a
|
|
24
|
+
* DB driver; bring postgres-js, `Bun.sql`, or anything else.
|
|
25
|
+
*/
|
|
26
|
+
create: () => Promise<Conn> | Conn;
|
|
27
|
+
/**
|
|
28
|
+
* Global in-use cap — the whole point of the broker. One shard hosting
|
|
29
|
+
* many tenants shares this many upstream connections, total, no matter
|
|
30
|
+
* how many tenants lease concurrently. Required, must be > 0.
|
|
31
|
+
*/
|
|
32
|
+
maxTotal: number;
|
|
33
|
+
/**
|
|
34
|
+
* Give up on a queued lease after this many milliseconds and reject
|
|
35
|
+
* with {@link LeaseTimeoutError}. Defaults to waiting forever.
|
|
36
|
+
*/
|
|
37
|
+
acquireTimeoutMs?: number;
|
|
38
|
+
/**
|
|
39
|
+
* Close one upstream connection. Called for validation failures, idle
|
|
40
|
+
* releases, and `dispose()`. Defaults to dropping the reference.
|
|
41
|
+
*/
|
|
42
|
+
destroy?: (conn: Conn) => Promise<void> | void;
|
|
43
|
+
/**
|
|
44
|
+
* Destroy pooled-idle connections that have sat unused for this many
|
|
45
|
+
* milliseconds, so a burst doesn't pin connections against the managed
|
|
46
|
+
* provider's limit forever. Swept on an unref'd timer and lazily on
|
|
47
|
+
* every `lease`. Defaults to keeping idle connections indefinitely.
|
|
48
|
+
*/
|
|
49
|
+
idleReleaseMs?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Per-tenant in-use cap, independent of `maxTotal` — one noisy tenant
|
|
52
|
+
* queues against its own budget instead of starving the shard.
|
|
53
|
+
* Defaults to no per-tenant cap.
|
|
54
|
+
*/
|
|
55
|
+
maxPerTenant?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Clock used for idle-age accounting. Injectable so idle-release tests
|
|
58
|
+
* run without real timers. Defaults to `Date.now`.
|
|
59
|
+
*/
|
|
60
|
+
now?: () => number;
|
|
61
|
+
/**
|
|
62
|
+
* Called when `create`, `destroy`, or `validate` throws. The broker
|
|
63
|
+
* stays consistent either way (a failed create rejects that lease and
|
|
64
|
+
* frees the slot; a failed destroy drops the connection reference).
|
|
65
|
+
* Defaults to a no-op.
|
|
66
|
+
*/
|
|
67
|
+
onError?: (error: unknown, phase: 'create' | 'destroy' | 'validate') => void;
|
|
68
|
+
/**
|
|
69
|
+
* Optional OpenTelemetry tracer provider. When set, every lease is
|
|
70
|
+
* traced as a `sync.broker_lease` span carrying `abs.tenant` and the
|
|
71
|
+
* queue wait in milliseconds. Noop when unset.
|
|
72
|
+
*/
|
|
73
|
+
tracerProvider?: TelemetryTracerProvider;
|
|
74
|
+
/**
|
|
75
|
+
* Health-check a pooled connection before reuse. Return false (or
|
|
76
|
+
* throw) and the broker destroys it and tries the next idle connection
|
|
77
|
+
* — or creates a fresh one — so a server-side idle disconnect never
|
|
78
|
+
* reaches a caller. Defaults to trusting pooled connections.
|
|
79
|
+
*/
|
|
80
|
+
validate?: (conn: Conn) => Promise<boolean> | boolean;
|
|
81
|
+
};
|
|
82
|
+
export type ConnectionLease<Conn> = {
|
|
83
|
+
conn: Conn;
|
|
84
|
+
/** Return the connection to the pool. Idempotent. */
|
|
85
|
+
release: () => void;
|
|
86
|
+
};
|
|
87
|
+
export type ConnectionBrokerMetrics = {
|
|
88
|
+
/** Connections currently leased out (or reserved for an in-flight create). */
|
|
89
|
+
inUse: number;
|
|
90
|
+
/** Connections sitting in the idle pool. */
|
|
91
|
+
idle: number;
|
|
92
|
+
/** Lease calls waiting for a connection to free up. */
|
|
93
|
+
queued: number;
|
|
94
|
+
/** In-use count per tenant — only tenants with at least one lease appear. */
|
|
95
|
+
byTenant: Record<string, number>;
|
|
96
|
+
cumulative: {
|
|
97
|
+
leases: number;
|
|
98
|
+
releases: number;
|
|
99
|
+
timeouts: number;
|
|
100
|
+
created: number;
|
|
101
|
+
destroyed: number;
|
|
102
|
+
validationFailures: number;
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
export type ConnectionBroker<Conn> = {
|
|
106
|
+
/**
|
|
107
|
+
* Lease a connection for a tenant. Resolves immediately when under
|
|
108
|
+
* both caps (reusing the most-recently-released idle connection first,
|
|
109
|
+
* for cache warmth); otherwise queues FIFO until a release frees the
|
|
110
|
+
* cap that blocked it.
|
|
111
|
+
*/
|
|
112
|
+
lease: (tenant: string) => Promise<ConnectionLease<Conn>>;
|
|
113
|
+
/** Lease, run `fn`, release in `finally` — even when `fn` throws. */
|
|
114
|
+
withLease: <Result>(tenant: string, fn: (conn: Conn) => Promise<Result> | Result) => Promise<Result>;
|
|
115
|
+
/** Operator-shaped snapshot, same spirit as `engine.metrics()`. */
|
|
116
|
+
metrics: () => ConnectionBrokerMetrics;
|
|
117
|
+
/**
|
|
118
|
+
* Stop issuing new leases ({@link ConnectionBrokerDrainedError}) and
|
|
119
|
+
* resolve once every outstanding lease has been released and the queue
|
|
120
|
+
* has drained through. Follow with `dispose()` to close idle
|
|
121
|
+
* connections.
|
|
122
|
+
*/
|
|
123
|
+
drain: () => Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* Drain-if-needed, reject queued waiters, destroy every idle
|
|
126
|
+
* connection, and stop the sweep timer. Connections still leased out
|
|
127
|
+
* are destroyed on their release instead of returning to the pool.
|
|
128
|
+
*/
|
|
129
|
+
dispose: () => Promise<void>;
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* A generic connection lease broker: multiplex ONE upstream connection
|
|
133
|
+
* budget across many tenants.
|
|
134
|
+
*
|
|
135
|
+
* The BYO-Postgres problem this solves: one shard hosting 50 customers,
|
|
136
|
+
* each spawning its own PG pool, instantly exceeds a managed provider's
|
|
137
|
+
* connection limit. Instead, give every tenant `broker.lease(tenant)`
|
|
138
|
+
* over a single caller-supplied `create`/`destroy` pair — the broker
|
|
139
|
+
* enforces a global in-use cap (`maxTotal`), optional per-tenant budgets
|
|
140
|
+
* (`maxPerTenant`), FIFO queueing with timeouts when at cap, LIFO idle
|
|
141
|
+
* reuse for cache warmth, and idle harvesting (`idleReleaseMs`) so a
|
|
142
|
+
* burst doesn't pin connections forever.
|
|
143
|
+
*
|
|
144
|
+
* The broker never imports a DB driver — `Conn` is whatever `create`
|
|
145
|
+
* returns (a postgres-js `sql`, a `Bun.sql`, an HTTP client, …).
|
|
146
|
+
*/
|
|
147
|
+
export declare const createConnectionBroker: <Conn>(options: ConnectionBrokerOptions<Conn>) => ConnectionBroker<Conn>;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { createWriteBehindCache } from './writeBehindCache';
|
|
2
2
|
export type { WriteBehindCache, WriteBehindCacheOptions } from './writeBehindCache';
|
|
3
|
+
export { ConnectionBrokerDrainedError, createConnectionBroker, LeaseTimeoutError } from './connectionBroker';
|
|
4
|
+
export type { ConnectionBroker, ConnectionBrokerMetrics, ConnectionBrokerOptions, ConnectionLease } from './connectionBroker';
|
|
3
5
|
export { createReactiveHub } from './reactiveHub';
|
|
4
6
|
export type { ReactiveEvent, ReactiveHub, ReactiveListener } from './reactiveHub';
|
|
5
7
|
export { sync } from './plugin';
|