@rebasepro/types 0.10.0 → 0.10.1-canary.14e53ae
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/dist/controllers/client.d.ts +3 -1
- package/dist/controllers/data_driver.d.ts +29 -0
- package/dist/index.es.js +270 -1
- package/dist/index.es.js.map +1 -1
- package/dist/types/backend.d.ts +30 -1
- package/dist/types/channel_bus.d.ts +192 -0
- package/dist/types/collection_contract.d.ts +43 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/project_manifest.d.ts +336 -0
- package/dist/types/schema_version.d.ts +18 -0
- package/dist/types/storage_authorize.d.ts +74 -0
- package/package.json +1 -1
- package/src/controllers/client.ts +3 -2
- package/src/controllers/data_driver.ts +56 -0
- package/src/types/backend.ts +33 -1
- package/src/types/channel_bus.ts +202 -0
- package/src/types/collection_contract.ts +249 -0
- package/src/types/index.ts +5 -0
- package/src/types/policy.ts +25 -11
- package/src/types/project_manifest.ts +362 -0
- package/src/types/schema_version.ts +112 -0
- package/src/types/storage_authorize.ts +77 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storage access-control contract.
|
|
3
|
+
*
|
|
4
|
+
* Lives here rather than in `@rebasepro/server` because a project declares its
|
|
5
|
+
* `storageAuthorize` hook from its **config package**, and a config package
|
|
6
|
+
* depends on `@rebasepro/types` alone. A type the contract requires but the
|
|
7
|
+
* contract's author cannot import is not much of a contract.
|
|
8
|
+
*
|
|
9
|
+
* `@rebasepro/server` re-exports all of this, so existing imports keep working.
|
|
10
|
+
*/
|
|
11
|
+
export type StorageOperation = "read" | "write" | "delete" | "list";
|
|
12
|
+
/** The caller, as resolved by whichever auth middleware ran. */
|
|
13
|
+
export interface StorageAuthorizeUser {
|
|
14
|
+
uid: string;
|
|
15
|
+
email?: string;
|
|
16
|
+
roles?: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface StorageAuthorizeContext {
|
|
19
|
+
/** Object key, bucket prefix stripped and traversal already sanitized. */
|
|
20
|
+
key: string;
|
|
21
|
+
bucket: string;
|
|
22
|
+
operation: StorageOperation;
|
|
23
|
+
/** Null when the route allows unauthenticated access. */
|
|
24
|
+
user: StorageAuthorizeUser | null;
|
|
25
|
+
/** Named backend the request targeted, when one was given. */
|
|
26
|
+
storageId?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Trusted, RLS-bypassing data access, so the hook can answer the question it
|
|
29
|
+
* actually has to answer: *who owns this object?*
|
|
30
|
+
*
|
|
31
|
+
* Without it the hook can only do prefix arithmetic on the key, which
|
|
32
|
+
* expresses no real multi-tenant rule — ownership lives in a row, not in a
|
|
33
|
+
* string. And it cannot simply import the server to get one: a project
|
|
34
|
+
* declares this hook from its **config** package, which depends on
|
|
35
|
+
* `@rebasepro/types` alone and cannot resolve `@rebasepro/server` at
|
|
36
|
+
* runtime. So the accessor is handed in.
|
|
37
|
+
*
|
|
38
|
+
* It bypasses row-level security on purpose. The hook IS the authorization
|
|
39
|
+
* decision; asking it to make that decision through a reader that has
|
|
40
|
+
* already been narrowed by the caller's own permissions is circular.
|
|
41
|
+
*/
|
|
42
|
+
data?: StorageAuthorizeData;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The slice of the data API a storage hook needs: read a collection, in the
|
|
46
|
+
* trusted server context.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately read-only and deliberately tiny. A hook that can write is a hook
|
|
49
|
+
* that can be tricked into writing, and an authorization check has no business
|
|
50
|
+
* mutating anything.
|
|
51
|
+
*/
|
|
52
|
+
export interface StorageAuthorizeData {
|
|
53
|
+
collection(slug: string): {
|
|
54
|
+
find(query?: Record<string, unknown>): Promise<{
|
|
55
|
+
data: Record<string, unknown>[];
|
|
56
|
+
}>;
|
|
57
|
+
findById(id: string): Promise<Record<string, unknown> | null>;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Per-object access control for storage, the analogue of an RLS policy on a
|
|
62
|
+
* collection.
|
|
63
|
+
*
|
|
64
|
+
* Without it, storage routes authenticate but do not authorize: `requireAuth`
|
|
65
|
+
* and `publicRead` are global switches, so any signed-in user could read any
|
|
66
|
+
* key they could name. That is fine for a single-tenant app and useless for a
|
|
67
|
+
* multi-tenant one, where the only thing separating two tenants' files would be
|
|
68
|
+
* key unguessability — not an access-control model. Multi-tenant apps were
|
|
69
|
+
* left to route every byte through a custom function to get an ownership check
|
|
70
|
+
* in, and each of them had to invent it.
|
|
71
|
+
*
|
|
72
|
+
* Return false (or throw) to deny. Denials surface as 403.
|
|
73
|
+
*/
|
|
74
|
+
export type StorageAuthorize = (ctx: StorageAuthorizeContext) => boolean | Promise<boolean>;
|
package/package.json
CHANGED
|
@@ -384,9 +384,10 @@ export interface RebaseServerClient<DB = unknown> extends RebaseClient<DB> {
|
|
|
384
384
|
|
|
385
385
|
/**
|
|
386
386
|
* Execute raw SQL against the database. Always present server-side for SQL
|
|
387
|
-
* engines.
|
|
387
|
+
* engines. Values interpolated into the query should be passed via
|
|
388
|
+
* `params`, referenced as `$1`, `$2`, … placeholders in the query text.
|
|
388
389
|
*/
|
|
389
|
-
sql(query: string, options?: { database?: string; role?: string }): Promise<Record<string, unknown>[]>;
|
|
390
|
+
sql(query: string, options?: { database?: string; role?: string; params?: unknown[] }): Promise<Record<string, unknown>[]>;
|
|
390
391
|
}
|
|
391
392
|
|
|
392
393
|
// ─── RebaseBrowserClient ─────────────────────────────────────────────────────
|
|
@@ -40,6 +40,62 @@ export interface VectorSearchParams {
|
|
|
40
40
|
threshold?: number;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// ── List pagination bounds ────────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// Client-driven list reads (REST `GET /<collection>` and the WebSocket
|
|
46
|
+
// `subscribe_collection` message) accept a client-supplied `limit`. Without
|
|
47
|
+
// bounds, an ABSENT limit streams the entire table into memory — a trivial
|
|
48
|
+
// OOM/DoS — and `limit=100000000` (or `limit=0`, historically an unlimited
|
|
49
|
+
// bypass) is honoured verbatim. `resolveClientListLimit` is the single shared
|
|
50
|
+
// enforcement point so every untrusted ingress behaves identically. Trusted
|
|
51
|
+
// server-side callers build fetch options directly and are intentionally NOT
|
|
52
|
+
// bounded here (migrations, admin exports, and CDC refetches may need the full
|
|
53
|
+
// set).
|
|
54
|
+
|
|
55
|
+
/** Rows returned for a plain / text-search list read when the client sends no `limit`. */
|
|
56
|
+
export const DEFAULT_LIST_LIMIT = 50;
|
|
57
|
+
/** Rows returned for a vector-search list read when the client sends no `limit`. */
|
|
58
|
+
export const DEFAULT_VECTOR_LIST_LIMIT = 10;
|
|
59
|
+
/** Hard ceiling clamped onto any client-supplied `limit`, on every surface. */
|
|
60
|
+
export const MAX_LIST_LIMIT = 1000;
|
|
61
|
+
|
|
62
|
+
/** Overridable bounds for {@link resolveClientListLimit}. */
|
|
63
|
+
export interface ListLimitBounds {
|
|
64
|
+
/** Default page size for plain and text-search reads. */
|
|
65
|
+
defaultLimit?: number;
|
|
66
|
+
/** Default page size for vector-search reads. */
|
|
67
|
+
vectorDefaultLimit?: number;
|
|
68
|
+
/** Upper bound clamped onto any client-supplied limit. */
|
|
69
|
+
maxLimit?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve a client-supplied list `limit` into a safe, always-defined value.
|
|
74
|
+
*
|
|
75
|
+
* - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
|
|
76
|
+
* so `0`, negatives, and absurd values can never bypass the cap.
|
|
77
|
+
* - An absent / blank / non-numeric limit falls back to the mode default:
|
|
78
|
+
* `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
|
|
79
|
+
*
|
|
80
|
+
* The return is never `undefined` — no ingress that routes its client limit
|
|
81
|
+
* through this can produce an unbounded read.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveClientListLimit(
|
|
84
|
+
rawLimit: number | string | null | undefined,
|
|
85
|
+
opts: ListLimitBounds & { vectorSearch?: boolean } = {}
|
|
86
|
+
): number {
|
|
87
|
+
const maxLimit = opts.maxLimit ?? MAX_LIST_LIMIT;
|
|
88
|
+
if (rawLimit != null && String(rawLimit).trim() !== "") {
|
|
89
|
+
const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
|
|
90
|
+
if (Number.isFinite(parsed)) {
|
|
91
|
+
return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return opts.vectorSearch
|
|
95
|
+
? (opts.vectorDefaultLimit ?? DEFAULT_VECTOR_LIST_LIMIT)
|
|
96
|
+
: (opts.defaultLimit ?? DEFAULT_LIST_LIMIT);
|
|
97
|
+
}
|
|
98
|
+
|
|
43
99
|
/**
|
|
44
100
|
* @internal
|
|
45
101
|
*/
|
package/src/types/backend.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { CollectionConfig, FilterValues, WhereFilterOp } from "./collections";
|
|
2
2
|
import type { AuthAdapter } from "./auth_adapter";
|
|
3
3
|
import type { HistoryConfig } from "../controllers/client";
|
|
4
|
+
import type { ChannelBusSetting } from "./channel_bus";
|
|
4
5
|
|
|
5
6
|
// =============================================================================
|
|
6
7
|
// DATABASE CONNECTION INTERFACES
|
|
@@ -272,13 +273,23 @@ export interface ChannelRetentionRule {
|
|
|
272
273
|
ttl?: number | string;
|
|
273
274
|
}
|
|
274
275
|
|
|
275
|
-
/**
|
|
276
|
+
/**
|
|
277
|
+
* Server-side realtime options.
|
|
278
|
+
*
|
|
279
|
+
* The channel bus contract and its config live in `./channel_bus` so that a
|
|
280
|
+
* transport shipped as its own package depends on the contract alone.
|
|
281
|
+
*/
|
|
276
282
|
export interface RealtimeChannelsConfig {
|
|
277
283
|
/**
|
|
278
284
|
* Retention rules, most specific first — the first match wins. Omitted or
|
|
279
285
|
* empty means no channel retains anything.
|
|
280
286
|
*/
|
|
281
287
|
channels?: ChannelRetentionRule[];
|
|
288
|
+
/**
|
|
289
|
+
* How channel broadcast and presence reach other backend instances.
|
|
290
|
+
* Defaults to `{ type: "memory" }` — i.e. they don't.
|
|
291
|
+
*/
|
|
292
|
+
bus?: ChannelBusSetting;
|
|
282
293
|
}
|
|
283
294
|
|
|
284
295
|
/**
|
|
@@ -766,6 +777,27 @@ export interface BackendBootstrapper {
|
|
|
766
777
|
*/
|
|
767
778
|
getAdmin?(driverResult: InitializedDriver): DatabaseAdmin | undefined;
|
|
768
779
|
|
|
780
|
+
/**
|
|
781
|
+
* Bring the database's collection tables up to date, additively.
|
|
782
|
+
*
|
|
783
|
+
* Optional because it is only meaningful for schema-ful drivers. A managed
|
|
784
|
+
* runtime boots a compiled project against a database it has never seen; auth
|
|
785
|
+
* tables are ensured on boot but collection tables were created by nothing,
|
|
786
|
+
* so every data request answered 500 on a missing relation. The CLI's `db
|
|
787
|
+
* push` cannot fill the gap — it needs Atlas, and the runtime image ships no
|
|
788
|
+
* CLI.
|
|
789
|
+
*
|
|
790
|
+
* Implementations MUST be additive-only: create missing tables, columns and
|
|
791
|
+
* enum types, and never drop, narrow or rewrite anything. This runs
|
|
792
|
+
* unattended against live customer data with nobody reading a diff, so the
|
|
793
|
+
* destructive half stays a deliberate migration.
|
|
794
|
+
*/
|
|
795
|
+
ensureCollectionSchema?(
|
|
796
|
+
collections: unknown[],
|
|
797
|
+
driverResult: InitializedDriver,
|
|
798
|
+
log?: (message: string) => void
|
|
799
|
+
): Promise<{ applied: number }>;
|
|
800
|
+
|
|
769
801
|
/**
|
|
770
802
|
* Initialize WebSocket server for realtime operations.
|
|
771
803
|
*/
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cross-instance transport for channel broadcast and presence, and the
|
|
3
|
+
* contract anyone implementing one has to meet.
|
|
4
|
+
*
|
|
5
|
+
* These types live in `@rebasepro/types` rather than in the Postgres adapter on
|
|
6
|
+
* purpose: a transport package should depend on the contract, not on the
|
|
7
|
+
* database driver that happens to ship the default implementation. A
|
|
8
|
+
* `@rebasepro/channel-bus-<something>` package needs this file and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* Why a transport exists at all: entity/collection realtime already spans
|
|
11
|
+
* instances (CDC, or per-mutation LISTEN/NOTIFY). Channel broadcast and presence
|
|
12
|
+
* did not — they fanned out from per-process maps, so two clients served by
|
|
13
|
+
* different replicas could not see each other, and nothing errored. The bus is
|
|
14
|
+
* the missing hop, and deliberately *only* that hop: which local clients receive
|
|
15
|
+
* a frame stays in the realtime service, so a transport never has to know what a
|
|
16
|
+
* subscription, a WebSocket or a presence roster is.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A frame in flight between instances.
|
|
21
|
+
*
|
|
22
|
+
* `sid` identifies the publishing instance. The realtime service drops frames
|
|
23
|
+
* carrying its own `sid` on arrival — local fan-out already happened before the
|
|
24
|
+
* publish — so a transport that echoes a publisher's own messages back to it is
|
|
25
|
+
* still correct, merely wasteful.
|
|
26
|
+
*
|
|
27
|
+
* Keys are spelled out rather than abbreviated. The one shipped transport with a
|
|
28
|
+
* size limit has a pointer path for anything that would approach it, so shaving
|
|
29
|
+
* bytes off key names buys nothing worth the opacity.
|
|
30
|
+
*/
|
|
31
|
+
export type ChannelBusFrame =
|
|
32
|
+
/** A broadcast carrying its payload. */
|
|
33
|
+
| {
|
|
34
|
+
kind: "broadcast";
|
|
35
|
+
sid: string;
|
|
36
|
+
channel: string;
|
|
37
|
+
event: string;
|
|
38
|
+
/** Originating client, echoed so receivers can skip it if it is theirs. */
|
|
39
|
+
from?: string;
|
|
40
|
+
/** Sequence number, present only on retained channels. */
|
|
41
|
+
seq?: number;
|
|
42
|
+
payload: unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A broadcast too large for the transport to carry inline: the body is
|
|
46
|
+
* already durable in `rebase.channel_messages`, so the frame carries only
|
|
47
|
+
* its address and each receiver reads it back. Only ever emitted for
|
|
48
|
+
* retained channels, and only by a transport with a finite
|
|
49
|
+
* {@link ChannelBus.maxFrameBytes}.
|
|
50
|
+
*/
|
|
51
|
+
| {
|
|
52
|
+
kind: "broadcast_ref";
|
|
53
|
+
sid: string;
|
|
54
|
+
channel: string;
|
|
55
|
+
from?: string;
|
|
56
|
+
seq: number;
|
|
57
|
+
}
|
|
58
|
+
/** A presence join/leave/update, small by construction. */
|
|
59
|
+
| {
|
|
60
|
+
kind: "presence_diff";
|
|
61
|
+
sid: string;
|
|
62
|
+
channel: string;
|
|
63
|
+
joins: Record<string, Record<string, unknown>>;
|
|
64
|
+
leaves: Record<string, Record<string, unknown>>;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Receives frames published by *other* instances. */
|
|
68
|
+
export type ChannelBusHandler = (frame: ChannelBusFrame) => void | Promise<void>;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A cross-instance transport.
|
|
72
|
+
*
|
|
73
|
+
* ## What an implementation must guarantee
|
|
74
|
+
*
|
|
75
|
+
* - **`start()` rejects if the transport is unusable.** The caller falls back to
|
|
76
|
+
* in-process delivery when it does. Resolving while disconnected produces a
|
|
77
|
+
* cluster that believes it is connected and silently is not, which is the
|
|
78
|
+
* exact failure this whole mechanism exists to remove.
|
|
79
|
+
* - **`publish()` reaches every *other* instance, or rejects.** Delivery back to
|
|
80
|
+
* the publisher is permitted but pointless (see {@link ChannelBusFrame.sid}).
|
|
81
|
+
* - **`stop()` is idempotent** and releases everything, including anything
|
|
82
|
+
* holding the event loop open.
|
|
83
|
+
* - **A malformed message never throws out of the transport.** Parsing happens
|
|
84
|
+
* inside the implementation; drop and log what you cannot understand, so one
|
|
85
|
+
* bad frame cannot take the listener down.
|
|
86
|
+
*
|
|
87
|
+
* ## What it does *not* have to guarantee
|
|
88
|
+
*
|
|
89
|
+
* - **Ordering.** Retained channels carry `seq`, and the client SDK orders by
|
|
90
|
+
* it. Unsequenced broadcasts are cursor-grade traffic where order is not
|
|
91
|
+
* meaningful.
|
|
92
|
+
* - **Durability.** A frame lost in transit is a missed live update; retained
|
|
93
|
+
* channels repair themselves through the client's `channel_history` replay.
|
|
94
|
+
* - **Exactly-once.** Duplicates are tolerated — retained frames are deduped by
|
|
95
|
+
* `seq`, and presence diffs are idempotent by construction.
|
|
96
|
+
*/
|
|
97
|
+
export interface ChannelBus {
|
|
98
|
+
/**
|
|
99
|
+
* Identifies the transport in logs and in `getChannelBusKind()`. Use your
|
|
100
|
+
* own name; the framework only compares against `"memory"` to decide
|
|
101
|
+
* whether publishing is worth attempting at all.
|
|
102
|
+
*/
|
|
103
|
+
readonly kind: string;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Largest frame this transport will carry, in bytes of encoded JSON, or
|
|
107
|
+
* `Infinity` when there is no meaningful ceiling.
|
|
108
|
+
*
|
|
109
|
+
* A broadcast that exceeds it is published as a `broadcast_ref` pointer when
|
|
110
|
+
* the channel is retained, and refused with an error to the sender when it
|
|
111
|
+
* is not. Implementations with no limit should return `Infinity` rather than
|
|
112
|
+
* a large number, so the pointer path is never taken needlessly.
|
|
113
|
+
*/
|
|
114
|
+
readonly maxFrameBytes: number;
|
|
115
|
+
|
|
116
|
+
/** Connect and begin delivering remote frames to `handler`. */
|
|
117
|
+
start(handler: ChannelBusHandler): Promise<void>;
|
|
118
|
+
|
|
119
|
+
/** Publish a frame to the other instances. */
|
|
120
|
+
publish(frame: ChannelBusFrame): Promise<void>;
|
|
121
|
+
|
|
122
|
+
/** Disconnect and release resources. Idempotent. */
|
|
123
|
+
stop(): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Which transport to use, for the two that ship with the Postgres adapter.
|
|
128
|
+
*
|
|
129
|
+
* To use one that does not ship here — a Redis package, or your own class —
|
|
130
|
+
* pass the {@link ChannelBus} instance itself instead of a config object.
|
|
131
|
+
*
|
|
132
|
+
* There are deliberately only two built in, and neither adds a service to a
|
|
133
|
+
* deployment. Rebase deploys as Postgres + backend + frontend; a bus that
|
|
134
|
+
* required a message broker would put a second stateful service into every
|
|
135
|
+
* `docker-compose.yml` the CLI scaffolds, for a feature most applications never
|
|
136
|
+
* use. Measured across two backend instances against one Postgres container,
|
|
137
|
+
* the Postgres bus carried ~10k cross-instance messages/second with no losses,
|
|
138
|
+
* and stayed flat out to eight instances — comfortably past what live-cursor
|
|
139
|
+
* collaboration generates. The extension point below is the answer for anyone
|
|
140
|
+
* who does outgrow it.
|
|
141
|
+
*/
|
|
142
|
+
export type ChannelBusConfig =
|
|
143
|
+
/**
|
|
144
|
+
* In-process only — the historical behaviour. Broadcast and presence reach
|
|
145
|
+
* the clients connected to *this* instance and no further.
|
|
146
|
+
*/
|
|
147
|
+
| { type: "memory" }
|
|
148
|
+
/**
|
|
149
|
+
* Postgres LISTEN/NOTIFY, reusing infrastructure the deployment already has.
|
|
150
|
+
*
|
|
151
|
+
* `pg_notify` caps a payload at 8000 bytes, so a broadcast larger than that
|
|
152
|
+
* is delivered cross-instance only on a *retained* channel, where the
|
|
153
|
+
* notification carries a pointer (`seq`) instead of the message and each
|
|
154
|
+
* receiver reads the body back from `rebase.channel_messages`. An oversized
|
|
155
|
+
* broadcast on an ephemeral channel is refused rather than silently
|
|
156
|
+
* delivered to half the cluster.
|
|
157
|
+
*
|
|
158
|
+
* NOTE: `LISTEN` needs a session-mode connection. Behind PgBouncer in
|
|
159
|
+
* transaction mode this must point at the database directly
|
|
160
|
+
* (`DATABASE_DIRECT_URL`), not at the pooler.
|
|
161
|
+
*/
|
|
162
|
+
| {
|
|
163
|
+
type: "postgres";
|
|
164
|
+
/** Direct connection for the LISTEN client. Defaults to `DATABASE_DIRECT_URL`. */
|
|
165
|
+
connectionString?: string;
|
|
166
|
+
/**
|
|
167
|
+
* How long to coalesce outgoing frames into a single notification, in
|
|
168
|
+
* milliseconds. Defaults to 10.
|
|
169
|
+
*
|
|
170
|
+
* A notify is a query on your primary database, so under load this is
|
|
171
|
+
* the difference between one query per message and one per window. The
|
|
172
|
+
* window is leading-edge: a frame arriving when none is open goes out
|
|
173
|
+
* immediately, so an idle channel pays no added latency and only a
|
|
174
|
+
* sustained stream is batched.
|
|
175
|
+
*
|
|
176
|
+
* Set to 0 to disable coalescing and send every frame on its own.
|
|
177
|
+
*/
|
|
178
|
+
batchWindowMs?: number;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* What `realtime.bus` accepts: a built-in transport by name, or any
|
|
183
|
+
* {@link ChannelBus} instance.
|
|
184
|
+
*
|
|
185
|
+
* ```typescript
|
|
186
|
+
* realtime: { bus: { type: "postgres" } } // shipped
|
|
187
|
+
* realtime: { bus: new MyRedisChannelBus(url) } // a separate package, or your own
|
|
188
|
+
* ```
|
|
189
|
+
*/
|
|
190
|
+
export type ChannelBusSetting = ChannelBusConfig | ChannelBus;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether `setting` is an already-constructed transport rather than a request
|
|
194
|
+
* for a built-in one.
|
|
195
|
+
*
|
|
196
|
+
* Structural rather than nominal so that an instance from a *different copy* of
|
|
197
|
+
* `@rebasepro/types` — an entirely normal outcome of a separately versioned
|
|
198
|
+
* transport package — is still recognised.
|
|
199
|
+
*/
|
|
200
|
+
export function isChannelBusInstance(setting: ChannelBusSetting | undefined): setting is ChannelBus {
|
|
201
|
+
return typeof (setting as ChannelBus | undefined)?.publish === "function";
|
|
202
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { CollectionConfig } from "./collections";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serializing collections so they survive a network hop.
|
|
5
|
+
*
|
|
6
|
+
* A collection definition is not plain data. Relations point at their target
|
|
7
|
+
* with a *function* (`target: () => usersCollection`) so two collections can
|
|
8
|
+
* reference each other without an import cycle, and collections also carry
|
|
9
|
+
* callbacks, custom views and component references. `JSON.stringify` silently
|
|
10
|
+
* drops every one of those, which matters because the SDK generator *calls*
|
|
11
|
+
* `relation.target()` to decide whether a foreign key is a string or a number.
|
|
12
|
+
* Serialize naively and remote SDK generation produces subtly wrong types
|
|
13
|
+
* instead of failing — the worst possible outcome.
|
|
14
|
+
*
|
|
15
|
+
* So relation targets are resolved to a slug reference on the way out and
|
|
16
|
+
* rebuilt into functions on the way in. Everything else that cannot cross a wire
|
|
17
|
+
* is dropped deliberately: an SDK is generated from the *shape* of the data, and
|
|
18
|
+
* server-side behaviour is neither useful to a client nor safe to publish.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Marker replacing a relation's `target` function in serialized form. */
|
|
22
|
+
export interface SerializedCollectionRef {
|
|
23
|
+
__collectionRef: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isSerializedCollectionRef(value: unknown): value is SerializedCollectionRef {
|
|
27
|
+
return typeof value === "object"
|
|
28
|
+
&& value !== null
|
|
29
|
+
&& typeof (value as SerializedCollectionRef).__collectionRef === "string";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Depth limit for the walk — deep enough for real configs, finite for cyclic ones. */
|
|
33
|
+
const MAX_DEPTH = 64;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve whatever a `target` thunk returns down to a collection.
|
|
37
|
+
*
|
|
38
|
+
* A target may be the collection, a module namespace (when the authoring file
|
|
39
|
+
* used `import * as`), or a default-export wrapper. All three appear in real
|
|
40
|
+
* projects, and the SDK generator already unwraps them the same way.
|
|
41
|
+
*/
|
|
42
|
+
function unwrapTarget(value: unknown): CollectionConfig | undefined {
|
|
43
|
+
if (!value || typeof value !== "object") return undefined;
|
|
44
|
+
const candidate = value as { default?: unknown; __esModule?: boolean; properties?: unknown };
|
|
45
|
+
if (candidate.default || candidate.__esModule) {
|
|
46
|
+
const inner = candidate.default;
|
|
47
|
+
if (inner && typeof inner === "object") return inner as CollectionConfig;
|
|
48
|
+
}
|
|
49
|
+
if (candidate.properties) return value as CollectionConfig;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The identity a serialized reference uses. Slug first — it is the routing key. */
|
|
54
|
+
function refFor(collection: CollectionConfig | undefined): string | undefined {
|
|
55
|
+
if (!collection) return undefined;
|
|
56
|
+
const withPath = collection as CollectionConfig & { path?: string };
|
|
57
|
+
return collection.slug || withPath.path || collection.name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Deep-copy a value into something JSON can carry.
|
|
62
|
+
*
|
|
63
|
+
* `target` keys are special-cased into refs. Other functions vanish, cycles are
|
|
64
|
+
* cut, and everything else is copied structurally.
|
|
65
|
+
*/
|
|
66
|
+
/** Shared walk state: the memo, plus a count of depth-cap hits. */
|
|
67
|
+
interface WalkState {
|
|
68
|
+
memo: WeakMap<object, unknown>;
|
|
69
|
+
/**
|
|
70
|
+
* How many times the walk has truncated a subtree — by hitting the depth
|
|
71
|
+
* cap, or by cutting a cycle.
|
|
72
|
+
*
|
|
73
|
+
* Either kind of truncation makes a result valid only at the *position* it
|
|
74
|
+
* was produced at, so caching it and serving it elsewhere silently drops
|
|
75
|
+
* content that would have been included. Comparing this counter before and
|
|
76
|
+
* after a node's children tells us whether its result is position-
|
|
77
|
+
* independent and therefore safe to memoize.
|
|
78
|
+
*
|
|
79
|
+
* The cycle case is the subtle one: with `a.b = b` and `b.a = a`, serializing
|
|
80
|
+
* `{ first: b, second: a }` visits `a` beneath `b` — where the cycle back to
|
|
81
|
+
* `b` is cut — and would then reuse that truncated `a` for `second`, where
|
|
82
|
+
* nothing needed cutting.
|
|
83
|
+
*/
|
|
84
|
+
truncations: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function toSerializable(
|
|
88
|
+
value: unknown,
|
|
89
|
+
seen: WeakSet<object>,
|
|
90
|
+
depth: number,
|
|
91
|
+
state: WalkState,
|
|
92
|
+
key?: string
|
|
93
|
+
): unknown {
|
|
94
|
+
if (depth > MAX_DEPTH) {
|
|
95
|
+
state.truncations++;
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (typeof value === "function") {
|
|
100
|
+
// Only a relation target carries information a client needs. Calling it
|
|
101
|
+
// is safe here — this runs on the server, where the target module is
|
|
102
|
+
// already loaded — and a throwing target simply yields no reference,
|
|
103
|
+
// which degrades the generated FK type rather than failing the request.
|
|
104
|
+
if (key === "target") {
|
|
105
|
+
try {
|
|
106
|
+
const resolved = unwrapTarget((value as () => unknown)());
|
|
107
|
+
const ref = refFor(resolved);
|
|
108
|
+
return ref ? { __collectionRef: ref } : undefined;
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (value === null || typeof value !== "object") {
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (value instanceof Date) return value.toISOString();
|
|
121
|
+
if (value instanceof RegExp) return value.source;
|
|
122
|
+
|
|
123
|
+
if (seen.has(value as object)) {
|
|
124
|
+
state.truncations++;
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A shared (non-cyclic) subgraph is reachable by many paths, and `seen` is a
|
|
129
|
+
// *path* set — released in the `finally` below so a node referenced twice in
|
|
130
|
+
// different branches is emitted twice rather than dropped as a false cycle.
|
|
131
|
+
// Without memoization that makes the walk exponential in depth: a diamond
|
|
132
|
+
// graph 20 levels deep took ~400ms, and each further level doubled it. The
|
|
133
|
+
// result is a plain data tree, so handing back the same converted object for
|
|
134
|
+
// a repeat visit is indistinguishable after JSON.stringify.
|
|
135
|
+
const cached = state.memo.get(value as object);
|
|
136
|
+
if (cached !== undefined) return cached;
|
|
137
|
+
|
|
138
|
+
seen.add(value as object);
|
|
139
|
+
const truncationsBefore = state.truncations;
|
|
140
|
+
const memoize = (result: unknown): unknown => {
|
|
141
|
+
// Only cache a result that nothing was cut from.
|
|
142
|
+
if (result !== undefined && state.truncations === truncationsBefore) {
|
|
143
|
+
state.memo.set(value as object, result);
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
const items = value
|
|
151
|
+
.map(item => toSerializable(item, seen, depth + 1, state))
|
|
152
|
+
.filter(item => item !== undefined);
|
|
153
|
+
// A container that had content, none of which can be represented, is
|
|
154
|
+
// itself unrepresentable — see the note below.
|
|
155
|
+
return memoize(value.length > 0 && items.length === 0 ? undefined : items);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A React element or component reference has no meaning to a client and
|
|
159
|
+
// will not survive JSON anyway.
|
|
160
|
+
if ("$$typeof" in (value as Record<string, unknown>)) return undefined;
|
|
161
|
+
|
|
162
|
+
const entries = Object.entries(value as Record<string, unknown>);
|
|
163
|
+
const out: Record<string, unknown> = {};
|
|
164
|
+
for (const [k, v] of entries) {
|
|
165
|
+
const converted = toSerializable(v, seen, depth + 1, state, k);
|
|
166
|
+
if (converted !== undefined) out[k] = converted;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Drop a container whose entire content was dropped.
|
|
170
|
+
//
|
|
171
|
+
// `callbacks: { beforeSave() {…} }` would otherwise serialize to
|
|
172
|
+
// `callbacks: {}` — an empty husk that carries no information but is not
|
|
173
|
+
// *nothing*, so it lands in the payload and, worse, in the schema hash.
|
|
174
|
+
// Editing a hook would then change every client's schema version and
|
|
175
|
+
// report perfectly current SDKs as stale.
|
|
176
|
+
//
|
|
177
|
+
// A container that started empty stays empty: `properties: {}` is a
|
|
178
|
+
// deliberate statement, not a casualty.
|
|
179
|
+
if (entries.length > 0 && Object.keys(out).length === 0) return undefined;
|
|
180
|
+
|
|
181
|
+
return memoize(out);
|
|
182
|
+
} finally {
|
|
183
|
+
// Released so a collection referenced twice in different branches is
|
|
184
|
+
// emitted twice rather than being dropped as a false cycle.
|
|
185
|
+
seen.delete(value as object);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Serialize collections for transport over the contract endpoint.
|
|
191
|
+
*
|
|
192
|
+
* Sorted by slug so the output — and therefore the schema hash computed from it
|
|
193
|
+
* — does not depend on filesystem ordering.
|
|
194
|
+
*/
|
|
195
|
+
export function serializeCollections(collections: CollectionConfig[]): unknown[] {
|
|
196
|
+
return [...collections]
|
|
197
|
+
.sort((a, b) => String(a.slug ?? "").localeCompare(String(b.slug ?? "")))
|
|
198
|
+
.map(collection => toSerializable(collection, new WeakSet(), 0, {
|
|
199
|
+
memo: new WeakMap(),
|
|
200
|
+
truncations: 0
|
|
201
|
+
}))
|
|
202
|
+
.filter((c): c is Record<string, unknown> => c !== undefined);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Rebuild collections received from a contract endpoint.
|
|
207
|
+
*
|
|
208
|
+
* Relation refs become real thunks resolving through the returned set, so
|
|
209
|
+
* downstream consumers — the SDK generator above all — see exactly the shape
|
|
210
|
+
* they would have seen had the collections been imported from source.
|
|
211
|
+
*
|
|
212
|
+
* A ref naming a collection that is not in the payload resolves to `undefined`
|
|
213
|
+
* rather than throwing: the generator already tolerates an unresolvable target
|
|
214
|
+
* by falling back to a permissive key type, and a partial contract should still
|
|
215
|
+
* produce a usable SDK.
|
|
216
|
+
*/
|
|
217
|
+
export function deserializeCollections(payload: unknown[]): CollectionConfig[] {
|
|
218
|
+
const collections = payload
|
|
219
|
+
.filter((c): c is Record<string, unknown> => typeof c === "object" && c !== null)
|
|
220
|
+
.map(c => ({ ...c })) as unknown as CollectionConfig[];
|
|
221
|
+
|
|
222
|
+
const bySlug = new Map<string, CollectionConfig>();
|
|
223
|
+
for (const collection of collections) {
|
|
224
|
+
const ref = refFor(collection);
|
|
225
|
+
if (ref) bySlug.set(ref, collection);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const rehydrate = (value: unknown, depth: number): void => {
|
|
229
|
+
if (depth > MAX_DEPTH || !value || typeof value !== "object") return;
|
|
230
|
+
|
|
231
|
+
if (Array.isArray(value)) {
|
|
232
|
+
for (const item of value) rehydrate(item, depth + 1);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const record = value as Record<string, unknown>;
|
|
237
|
+
for (const [key, child] of Object.entries(record)) {
|
|
238
|
+
if (key === "target" && isSerializedCollectionRef(child)) {
|
|
239
|
+
const slug = child.__collectionRef;
|
|
240
|
+
record.target = () => bySlug.get(slug);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
rehydrate(child, depth + 1);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
for (const collection of collections) rehydrate(collection, 0);
|
|
248
|
+
return collections;
|
|
249
|
+
}
|