@rebasepro/types 0.10.0 → 0.10.1-canary.0a881d4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/call_context.d.ts +51 -0
  2. package/dist/controllers/auth_state.d.ts +23 -0
  3. package/dist/controllers/client.d.ts +3 -1
  4. package/dist/controllers/collection_registry.d.ts +5 -1
  5. package/dist/controllers/data_driver.d.ts +31 -2
  6. package/dist/controllers/index.d.ts +1 -10
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.es.js +374 -1
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/types/admin_block.d.ts +34 -0
  11. package/dist/types/backend.d.ts +30 -1
  12. package/dist/types/channel_bus.d.ts +192 -0
  13. package/dist/types/collection_contract.d.ts +43 -0
  14. package/dist/types/collections.d.ts +17 -789
  15. package/dist/types/component_ref.d.ts +39 -6
  16. package/dist/types/entity_callbacks.d.ts +1 -1
  17. package/dist/types/index.d.ts +7 -15
  18. package/dist/types/project_manifest.d.ts +336 -0
  19. package/dist/types/properties.d.ts +12 -157
  20. package/dist/types/schema_version.d.ts +18 -0
  21. package/dist/types/security_rules.d.ts +320 -0
  22. package/dist/types/storage_authorize.d.ts +74 -0
  23. package/package.json +3 -6
  24. package/src/call_context.ts +56 -0
  25. package/src/controllers/auth_state.ts +24 -0
  26. package/src/controllers/client.ts +3 -2
  27. package/src/controllers/collection_registry.ts +5 -1
  28. package/src/controllers/data_driver.ts +58 -2
  29. package/src/controllers/index.ts +1 -10
  30. package/src/index.ts +1 -1
  31. package/src/types/admin_block.ts +75 -0
  32. package/src/types/backend.ts +33 -1
  33. package/src/types/channel_bus.ts +202 -0
  34. package/src/types/collection_contract.ts +278 -0
  35. package/src/types/collections.ts +25 -848
  36. package/src/types/component_ref.ts +40 -6
  37. package/src/types/entity_callbacks.ts +1 -1
  38. package/src/types/index.ts +7 -15
  39. package/src/types/policy.ts +25 -11
  40. package/src/types/project_manifest.ts +362 -0
  41. package/src/types/properties.ts +11 -169
  42. package/src/types/schema_version.ts +112 -0
  43. package/src/types/security_rules.ts +344 -0
  44. package/src/types/storage_authorize.ts +77 -0
  45. package/dist/controllers/analytics_controller.d.ts +0 -7
  46. package/dist/controllers/auth.d.ts +0 -110
  47. package/dist/controllers/customization_controller.d.ts +0 -61
  48. package/dist/controllers/dialogs_controller.d.ts +0 -36
  49. package/dist/controllers/local_config_persistence.d.ts +0 -20
  50. package/dist/controllers/navigation.d.ts +0 -225
  51. package/dist/controllers/registry.d.ts +0 -80
  52. package/dist/controllers/side_dialogs_controller.d.ts +0 -67
  53. package/dist/controllers/side_panel_controller.d.ts +0 -97
  54. package/dist/controllers/snackbar.d.ts +0 -24
  55. package/dist/rebase_context.d.ts +0 -110
  56. package/dist/types/breadcrumbs.d.ts +0 -26
  57. package/dist/types/builders.d.ts +0 -15
  58. package/dist/types/component_overrides.d.ts +0 -196
  59. package/dist/types/entity_actions.d.ts +0 -104
  60. package/dist/types/entity_link_builder.d.ts +0 -7
  61. package/dist/types/entity_views.d.ts +0 -95
  62. package/dist/types/export_import.d.ts +0 -21
  63. package/dist/types/formex.d.ts +0 -40
  64. package/dist/types/locales.d.ts +0 -4
  65. package/dist/types/modify_collections.d.ts +0 -5
  66. package/dist/types/plugins.d.ts +0 -277
  67. package/dist/types/property_config.d.ts +0 -74
  68. package/dist/types/slots.d.ts +0 -262
  69. package/dist/types/translations.d.ts +0 -915
  70. package/dist/types/user_management_delegate.d.ts +0 -22
  71. package/src/controllers/analytics_controller.tsx +0 -57
  72. package/src/controllers/auth.tsx +0 -121
  73. package/src/controllers/customization_controller.tsx +0 -72
  74. package/src/controllers/dialogs_controller.tsx +0 -37
  75. package/src/controllers/local_config_persistence.tsx +0 -22
  76. package/src/controllers/navigation.ts +0 -267
  77. package/src/controllers/registry.ts +0 -95
  78. package/src/controllers/side_dialogs_controller.tsx +0 -82
  79. package/src/controllers/side_panel_controller.tsx +0 -112
  80. package/src/controllers/snackbar.ts +0 -29
  81. package/src/rebase_context.tsx +0 -130
  82. package/src/types/breadcrumbs.ts +0 -27
  83. package/src/types/builders.ts +0 -17
  84. package/src/types/component_overrides.ts +0 -244
  85. package/src/types/entity_actions.tsx +0 -125
  86. package/src/types/entity_link_builder.ts +0 -8
  87. package/src/types/entity_views.tsx +0 -114
  88. package/src/types/export_import.ts +0 -26
  89. package/src/types/formex.ts +0 -45
  90. package/src/types/locales.ts +0 -81
  91. package/src/types/modify_collections.tsx +0 -6
  92. package/src/types/plugins.tsx +0 -346
  93. package/src/types/property_config.tsx +0 -95
  94. package/src/types/slots.tsx +0 -307
  95. package/src/types/translations.ts +0 -1026
  96. package/src/types/user_management_delegate.ts +0 -23
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/types",
3
3
  "type": "module",
4
- "version": "0.10.0",
4
+ "version": "0.10.1-canary.0a881d4",
5
5
  "description": "Rebase type definitions — shared interfaces and controller types",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -41,18 +41,15 @@
41
41
  "@types/jest": "^30.0.0",
42
42
  "@types/node": "^25.9.3",
43
43
  "@types/object-hash": "^3.0.6",
44
- "@types/react": "^19.2.17",
45
- "@types/react-dom": "^19.2.3",
46
44
  "@types/react-measure": "^2.0.12",
47
- "@vitejs/plugin-react": "^6.0.2",
48
- "hono": "^4.12.25",
45
+ "hono": "^4.12.27",
49
46
  "jest": "^30.4.2",
50
47
  "ts-jest": "^29.4.11",
51
48
  "typescript": "^6.0.3",
52
49
  "vite": "^8.0.16"
53
50
  },
54
51
  "peerDependencies": {
55
- "hono": "^4.12.21"
52
+ "hono": "^4.12.27"
56
53
  },
57
54
  "files": [
58
55
  "dist",
@@ -0,0 +1,56 @@
1
+ import type { StorageSource } from "./controllers/storage";
2
+ import type { RebaseClient } from "./controllers/client";
3
+ import type { RebaseSdkData } from "./controllers/data";
4
+ import type { User } from "./users";
5
+
6
+ /**
7
+ * Context that is provided to entity callbacks (hooks).
8
+ * It contains only the dependencies that are available in both the frontend and the backend.
9
+ *
10
+ * This is the *whole* context a collection callback gets, and it lives apart from
11
+ * {@link RebaseContext} on purpose. `RebaseContext` widens it with nine admin-panel
12
+ * controllers — navigation, side dialogs, snackbars — none of which exist in a
13
+ * backend process. Keeping them in one type meant every backend module that
14
+ * touched a callback signature transitively named the admin UI.
15
+ *
16
+ * @group Hooks and utilities
17
+ */
18
+ export type RebaseCallContext<USER extends User = User> = {
19
+
20
+ /**
21
+ * The Rebase client instance.
22
+ * Available in all entity callbacks (beforeSave, afterSave, afterRead,
23
+ * beforeDelete, afterDelete) and in CollectionActionsProps via context.
24
+ * Use it to call backend functions, access data, storage, etc.
25
+ *
26
+ * @example
27
+ * // In a beforeSave callback:
28
+ * const result = await context.client.functions.invoke('my-function', { ... });
29
+ *
30
+ * @example
31
+ * // In a CollectionAction component:
32
+ * const { client } = props.context;
33
+ * const result = await client.functions.invoke('extract-job', { url });
34
+ */
35
+ client: RebaseClient;
36
+
37
+ /**
38
+ * Unified data access — `context.data.products.create(...)`.
39
+ * Access any collection as a dynamic property.
40
+ *
41
+ * Returns flat rows (`{ id, ...columns }`), identical to the frontend SDK
42
+ * client — so `context.data` in a backend callback and `client.data` in the
43
+ * frontend behave the same way (`row.title`, never `row.values.title`).
44
+ */
45
+ data: RebaseSdkData;
46
+
47
+ /**
48
+ * Used storage implementation
49
+ */
50
+ storageSource: StorageSource;
51
+
52
+ /**
53
+ * Set by the backend when callbacks are executed on the server.
54
+ */
55
+ user?: USER;
56
+ }
@@ -0,0 +1,24 @@
1
+ import type { User } from "../users";
2
+
3
+ /**
4
+ * The read-only slice of authentication state that property resolution needs.
5
+ *
6
+ * `dynamicProps`, `conditions` and the JSON-Logic condition context all want the
7
+ * same one thing: who is asking. They used to be handed the entire
8
+ * {@link AuthController} — `signOut`, `googleLogin`, `authLoading`, thirty-odd
9
+ * members of frontend machinery — which meant `properties.ts` named a frontend
10
+ * controller, and so did `resolveProperty` in `@rebasepro/common`, which the
11
+ * Postgres schema generator calls at build time. A backend generating DDL had to
12
+ * satisfy a type with a login method in it.
13
+ *
14
+ * `User` already carries `roles` and `metadata`, so this is not a reduction in
15
+ * what a dynamic property can decide on — only in what it has to be given.
16
+ * An `AuthController` satisfies this structurally, so the frontend passes the
17
+ * controller it already has.
18
+ *
19
+ * @group Hooks and utilities
20
+ */
21
+ export interface AuthState<USER extends User = User> {
22
+ /** The signed-in user, or `null` when nobody is. */
23
+ user: USER | null;
24
+ }
@@ -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 ─────────────────────────────────────────────────────
@@ -15,8 +15,12 @@ export type CollectionRegistryController<
15
15
  * Each entry relates to a collection in the root database.
16
16
  * Each of the navigation entries in this field
17
17
  * generates an entry in the main menu.
18
+ *
19
+ * `EC`, like {@link getCollection} — this was hardcoded to `CollectionConfig`
20
+ * while `getCollection` honoured the parameter, so the admin panel got its
21
+ * view model from one and the raw contract from the other.
18
22
  */
19
- collections?: CollectionConfig[];
23
+ collections?: EC[];
20
24
 
21
25
  /**
22
26
  * Is the registry ready to be used
@@ -1,7 +1,7 @@
1
1
  import type { CollectionRegistryController } from "./collection_registry";
2
2
  import type { EntityStatus, EntityValues } from "../types/entities";
3
3
  import type { CollectionConfig, FilterValues } from "../types/collections";
4
- import type { RebaseContext } from "../rebase_context";
4
+ import type { RebaseCallContext } from "../call_context";
5
5
 
6
6
 
7
7
  /**
@@ -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
  */
@@ -246,7 +302,7 @@ export interface DataDriver {
246
302
  cmsToDelegateModel?: (data: unknown) => unknown;
247
303
 
248
304
  initTextSearch?: (props: {
249
- context: RebaseContext,
305
+ context: RebaseCallContext,
250
306
  path: string,
251
307
  databaseId?: string,
252
308
  collection: CollectionConfig,
@@ -1,21 +1,12 @@
1
1
  export * from "./collection_registry";
2
- export * from "./analytics_controller";
3
- export * from "./auth";
2
+ export * from "./auth_state";
4
3
 
5
4
  export * from "./data";
6
5
  export * from "./database_admin";
7
6
  export * from "./data_driver";
8
7
 
9
- export * from "./local_config_persistence";
10
- export * from "./navigation";
11
8
  export * from "./effective_role";
12
9
 
13
10
  export * from "./storage";
14
11
  export * from "./email";
15
12
  export * from "./client";
16
- export * from "./customization_controller";
17
- export * from "./side_panel_controller";
18
- export * from "./side_dialogs_controller";
19
- export * from "./dialogs_controller";
20
- export * from "./snackbar";
21
- export * from "./registry";
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export * from "./errors";
2
- export * from "./rebase_context";
2
+ export * from "./call_context";
3
3
  export * from "./types";
4
4
  export * from "./controllers";
5
5
  export * from "./users";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The keys of a collection's admin block, as data.
3
+ *
4
+ * There is no *type* for the block in this package any more, and that is the point:
5
+ * `admin` is not declared on `BaseCollectionConfig` or on any property here, so a
6
+ * BaaS install cannot even write one. `@rebasepro/admin-types` adds the field back by
7
+ * declaration merging, which is why installing it is what makes the admin surface
8
+ * appear.
9
+ *
10
+ * The *list* still has to live here, because three runtime consumers need it and two
11
+ * of them are core — see below.
12
+ */
13
+
14
+ /**
15
+ * Every key that belongs inside a collection's `admin` block, as data.
16
+ *
17
+ * The type that describes these fields is `AdminCollectionOptions` in
18
+ * `@rebasepro/admin-types`, and it is erased at build time — but three runtime
19
+ * consumers need the list, and two of them are core:
20
+ *
21
+ * - `serializeCollections`, to drop the block from the contract
22
+ * - the ts-morph schema editor in `@rebasepro/server`, which rewrites collection
23
+ * files on disk from the admin panel and has to know where each key goes. A key
24
+ * missing from this list gets written to the *top level* of the file, where the
25
+ * backend ignores it and the panel never finds it again.
26
+ * - the `collections-admin-block` codemod
27
+ *
28
+ * `@rebasepro/admin-types` re-exports this and asserts it names only real option
29
+ * keys; the count is pinned by a test there.
30
+ *
31
+ * @group Models
32
+ */
33
+ export const ADMIN_COLLECTION_KEYS = [
34
+ "Actions",
35
+ "additionalFields",
36
+ "alwaysApplyDefaultValues",
37
+ "components",
38
+ "defaultEntityAction",
39
+ "defaultFilter",
40
+ "defaultSelectedView",
41
+ "defaultSize",
42
+ "defaultViewMode",
43
+ "disableDefaultActions",
44
+ "enabledViews",
45
+ "entityActions",
46
+ "entityViews",
47
+ "exportable",
48
+ "filterPresets",
49
+ "fixedFilter",
50
+ "formAutoSave",
51
+ "formView",
52
+ "group",
53
+ "hideFromNavigation",
54
+ "hideIdFromCollection",
55
+ "hideIdFromForm",
56
+ "icon",
57
+ "includeJsonView",
58
+ "inlineEditing",
59
+ "kanban",
60
+ "listProperties",
61
+ "localChangesBackup",
62
+ "openEntityMode",
63
+ "orderProperty",
64
+ "pagination",
65
+ "previewProperties",
66
+ "propertiesOrder",
67
+ "selectionController",
68
+ "selectionEnabled",
69
+ "sideDialogWidth",
70
+ "sort",
71
+ "titleProperty"
72
+ ] as const;
73
+
74
+ /** A key of a collection's `admin` block. @group Models */
75
+ export type AdminCollectionKey = typeof ADMIN_COLLECTION_KEYS[number];
@@ -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
- /** Server-side realtime options. */
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
+ }