@tier0/sdk-v2 0.9.9 → 0.10.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/CHANGELOG.md +102 -0
- package/LICENSE +21 -21
- package/README.md +165 -27
- package/dist/cjs/client.d.ts +79 -3
- package/dist/cjs/client.js +4 -6
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/cloud-server.d.ts +33 -26
- package/dist/cjs/cloud-server.js +102 -100
- package/dist/cjs/cloud-server.js.map +1 -1
- package/dist/cjs/cloud-transport.d.ts +15 -20
- package/dist/cjs/cloud-transport.js +87 -68
- package/dist/cjs/cloud-transport.js.map +1 -1
- package/dist/cjs/connection-diagnostics.d.ts +2 -0
- package/dist/cjs/connection-diagnostics.js +20 -0
- package/dist/cjs/connection-diagnostics.js.map +1 -0
- package/dist/cjs/data/bootstrap.js +3 -4
- package/dist/cjs/data/bootstrap.js.map +1 -1
- package/dist/cjs/data/call.d.ts +1 -3
- package/dist/cjs/data/call.js +8 -12
- package/dist/cjs/data/call.js.map +1 -1
- package/dist/cjs/data/client.d.ts +8 -7
- package/dist/cjs/data/client.js +24 -11
- package/dist/cjs/data/client.js.map +1 -1
- package/dist/cjs/data/collection.d.ts +3 -5
- package/dist/cjs/data/collection.js +3 -4
- package/dist/cjs/data/collection.js.map +1 -1
- package/dist/cjs/data/context.d.ts +5 -11
- package/dist/cjs/data/context.js +21 -36
- package/dist/cjs/data/context.js.map +1 -1
- package/dist/cjs/data/error.d.ts +1 -1
- package/dist/cjs/data/error.js +3 -3
- package/dist/cjs/data/error.js.map +1 -1
- package/dist/cjs/data/index.d.ts +5 -3
- package/dist/cjs/data/index.js +5 -5
- package/dist/cjs/data/index.js.map +1 -1
- package/dist/cjs/data/relay.d.ts +51 -0
- package/dist/cjs/data/relay.js +371 -0
- package/dist/cjs/data/relay.js.map +1 -0
- package/dist/cjs/data/subscribe.d.ts +22 -38
- package/dist/cjs/data/subscribe.js +22 -28
- package/dist/cjs/data/subscribe.js.map +1 -1
- package/dist/cjs/event-stream.d.ts +28 -0
- package/dist/cjs/event-stream.js +222 -0
- package/dist/cjs/event-stream.js.map +1 -0
- package/dist/cjs/mq/client.d.ts +32 -1
- package/dist/cjs/mq/client.js +404 -69
- package/dist/cjs/mq/client.js.map +1 -1
- package/dist/cjs/mq/index.d.ts +1 -0
- package/dist/cjs/mq/index.js +3 -1
- package/dist/cjs/mq/index.js.map +1 -1
- package/dist/cjs/mq/topic.d.ts +29 -0
- package/dist/cjs/mq/topic.js +48 -0
- package/dist/cjs/mq/topic.js.map +1 -0
- package/dist/cjs/mq/types.d.ts +21 -1
- package/dist/cjs/mq/types.js.map +1 -1
- package/dist/cjs/openapi/api.generated.d.ts +20 -0
- package/dist/cjs/openapi/api.generated.js +11 -0
- package/dist/cjs/openapi/api.generated.js.map +1 -1
- package/dist/cjs/openapi/client.d.ts +46 -0
- package/dist/cjs/openapi/client.js +161 -12
- package/dist/cjs/openapi/client.js.map +1 -1
- package/dist/cjs/openapi/codecs.generated.d.ts +74 -0
- package/dist/cjs/openapi/codecs.generated.js +1 -1
- package/dist/cjs/openapi/codecs.generated.js.map +1 -1
- package/dist/cjs/openapi/types.generated.d.ts +205 -1
- package/dist/cjs/openapi/types.generated.js.map +1 -1
- package/dist/cjs/page-runtime.d.ts +48 -0
- package/dist/cjs/page-runtime.js +74 -0
- package/dist/cjs/page-runtime.js.map +1 -0
- package/dist/cjs/platform-proxy.d.ts +17 -0
- package/dist/cjs/platform-proxy.js +52 -0
- package/dist/cjs/platform-proxy.js.map +1 -0
- package/dist/cjs/runtime-context.d.ts +2 -1
- package/dist/cjs/runtime-context.js +6 -2
- package/dist/cjs/runtime-context.js.map +1 -1
- package/dist/cjs/runtime-env.js.map +1 -1
- package/dist/cjs/source.d.ts +19 -1
- package/dist/cjs/source.js +9 -3
- package/dist/cjs/source.js.map +1 -1
- package/dist/cjs/stream.js +3 -2
- package/dist/cjs/stream.js.map +1 -1
- package/dist/cjs/streams/contract.generated.d.ts +7 -1
- package/dist/cjs/streams/contract.generated.js.map +1 -1
- package/dist/cjs/streams/messages.js +1 -10
- package/dist/cjs/streams/messages.js.map +1 -1
- package/dist/cjs/streams/requests.d.ts +3 -0
- package/dist/cjs/streams/requests.js +5 -2
- package/dist/cjs/streams/requests.js.map +1 -1
- package/dist/cjs/streams/transport.d.ts +1 -13
- package/dist/cjs/streams/transport.js +2 -18
- package/dist/cjs/streams/transport.js.map +1 -1
- package/dist/cjs/uns-v2/client.d.ts +1 -6
- package/dist/cjs/uns-v2/client.js +33 -8
- package/dist/cjs/uns-v2/client.js.map +1 -1
- package/dist/cjs/uns-v2/data-client.d.ts +5 -2
- package/dist/cjs/uns-v2/data-client.js +7 -4
- package/dist/cjs/uns-v2/data-client.js.map +1 -1
- package/dist/cjs/uns-v2/generated.d.ts +6783 -4655
- package/dist/cjs/uns-v2/generated.js +221 -65
- package/dist/cjs/uns-v2/generated.js.map +1 -1
- package/dist/cjs/uns-v2/sse.d.ts +7 -1
- package/dist/cjs/uns-v2/sse.js +12 -2
- package/dist/cjs/uns-v2/sse.js.map +1 -1
- package/dist/esm/client.d.ts +79 -3
- package/dist/esm/client.js +5 -7
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cloud-server.d.ts +33 -26
- package/dist/esm/cloud-server.js +99 -97
- package/dist/esm/cloud-server.js.map +1 -1
- package/dist/esm/cloud-transport.d.ts +15 -20
- package/dist/esm/cloud-transport.js +86 -67
- package/dist/esm/cloud-transport.js.map +1 -1
- package/dist/esm/connection-diagnostics.d.ts +2 -0
- package/dist/esm/connection-diagnostics.js +17 -0
- package/dist/esm/connection-diagnostics.js.map +1 -0
- package/dist/esm/data/bootstrap.js +3 -4
- package/dist/esm/data/bootstrap.js.map +1 -1
- package/dist/esm/data/call.d.ts +1 -3
- package/dist/esm/data/call.js +9 -13
- package/dist/esm/data/call.js.map +1 -1
- package/dist/esm/data/client.d.ts +8 -7
- package/dist/esm/data/client.js +22 -9
- package/dist/esm/data/client.js.map +1 -1
- package/dist/esm/data/collection.d.ts +3 -5
- package/dist/esm/data/collection.js +3 -4
- package/dist/esm/data/collection.js.map +1 -1
- package/dist/esm/data/context.d.ts +5 -11
- package/dist/esm/data/context.js +19 -33
- package/dist/esm/data/context.js.map +1 -1
- package/dist/esm/data/error.d.ts +1 -1
- package/dist/esm/data/error.js +3 -3
- package/dist/esm/data/error.js.map +1 -1
- package/dist/esm/data/index.d.ts +5 -3
- package/dist/esm/data/index.js +3 -2
- package/dist/esm/data/index.js.map +1 -1
- package/dist/esm/data/relay.d.ts +51 -0
- package/dist/esm/data/relay.js +368 -0
- package/dist/esm/data/relay.js.map +1 -0
- package/dist/esm/data/subscribe.d.ts +22 -38
- package/dist/esm/data/subscribe.js +22 -27
- package/dist/esm/data/subscribe.js.map +1 -1
- package/dist/esm/event-stream.d.ts +28 -0
- package/dist/esm/event-stream.js +217 -0
- package/dist/esm/event-stream.js.map +1 -0
- package/dist/esm/mq/client.d.ts +32 -1
- package/dist/esm/mq/client.js +404 -69
- package/dist/esm/mq/client.js.map +1 -1
- package/dist/esm/mq/index.d.ts +1 -0
- package/dist/esm/mq/index.js +1 -0
- package/dist/esm/mq/index.js.map +1 -1
- package/dist/esm/mq/topic.d.ts +29 -0
- package/dist/esm/mq/topic.js +44 -0
- package/dist/esm/mq/topic.js.map +1 -0
- package/dist/esm/mq/types.d.ts +21 -1
- package/dist/esm/mq/types.js.map +1 -1
- package/dist/esm/openapi/api.generated.d.ts +20 -0
- package/dist/esm/openapi/api.generated.js +11 -0
- package/dist/esm/openapi/api.generated.js.map +1 -1
- package/dist/esm/openapi/client.d.ts +46 -0
- package/dist/esm/openapi/client.js +160 -12
- package/dist/esm/openapi/client.js.map +1 -1
- package/dist/esm/openapi/codecs.generated.d.ts +74 -0
- package/dist/esm/openapi/codecs.generated.js +1 -1
- package/dist/esm/openapi/codecs.generated.js.map +1 -1
- package/dist/esm/openapi/types.generated.d.ts +205 -1
- package/dist/esm/openapi/types.generated.js.map +1 -1
- package/dist/esm/page-runtime.d.ts +48 -0
- package/dist/esm/page-runtime.js +65 -0
- package/dist/esm/page-runtime.js.map +1 -0
- package/dist/esm/platform-proxy.d.ts +17 -0
- package/dist/esm/platform-proxy.js +47 -0
- package/dist/esm/platform-proxy.js.map +1 -0
- package/dist/esm/runtime-context.d.ts +2 -1
- package/dist/esm/runtime-context.js +6 -2
- package/dist/esm/runtime-context.js.map +1 -1
- package/dist/esm/runtime-env.js.map +1 -1
- package/dist/esm/source.d.ts +19 -1
- package/dist/esm/source.js +10 -4
- package/dist/esm/source.js.map +1 -1
- package/dist/esm/stream.js +4 -3
- package/dist/esm/stream.js.map +1 -1
- package/dist/esm/streams/contract.generated.d.ts +7 -1
- package/dist/esm/streams/contract.generated.js.map +1 -1
- package/dist/esm/streams/messages.js +1 -10
- package/dist/esm/streams/messages.js.map +1 -1
- package/dist/esm/streams/requests.d.ts +3 -0
- package/dist/esm/streams/requests.js +5 -2
- package/dist/esm/streams/requests.js.map +1 -1
- package/dist/esm/streams/transport.d.ts +1 -13
- package/dist/esm/streams/transport.js +1 -17
- package/dist/esm/streams/transport.js.map +1 -1
- package/dist/esm/uns-v2/client.d.ts +1 -6
- package/dist/esm/uns-v2/client.js +33 -8
- package/dist/esm/uns-v2/client.js.map +1 -1
- package/dist/esm/uns-v2/data-client.d.ts +5 -2
- package/dist/esm/uns-v2/data-client.js +8 -5
- package/dist/esm/uns-v2/data-client.js.map +1 -1
- package/dist/esm/uns-v2/generated.d.ts +6783 -4655
- package/dist/esm/uns-v2/generated.js +221 -65
- package/dist/esm/uns-v2/generated.js.map +1 -1
- package/dist/esm/uns-v2/sse.d.ts +7 -1
- package/dist/esm/uns-v2/sse.js +12 -2
- package/dist/esm/uns-v2/sse.js.map +1 -1
- package/package.json +25 -12
- package/skills/tier0-sdk-v2/SKILL.md +13 -5
- package/skills/tier0-sdk-v2/references/integration.md +14 -24
- package/skills/tier0-sdk-v2/references/local-connection.md +61 -0
- package/skills/tier0-sdk-v2/tier0-sdk-v2-data/SKILL.md +11 -5
- package/skills/tier0-sdk-v2/tier0-sdk-v2-data/references/consistency.md +8 -12
- package/skills/tier0-sdk-v2/tier0-sdk-v2-data/references/errors.md +24 -5
- package/skills/tier0-sdk-v2/tier0-sdk-v2-data/references/modeling.md +3 -1
- package/skills/tier0-sdk-v2/tier0-sdk-v2-data/references/read-write.md +46 -13
- package/skills/tier0-sdk-v2/tier0-sdk-v2-data/references/schema-evolution.md +2 -0
- package/skills/tier0-sdk-v2/tier0-sdk-v2-members/SKILL.md +2 -2
- package/skills/tier0-sdk-v2/tier0-sdk-v2-members/references/app-members.md +5 -1
- package/skills/tier0-sdk-v2/tier0-sdk-v2-mq/SKILL.md +25 -0
- package/skills/tier0-sdk-v2/tier0-sdk-v2-mq/references/quickstart.md +1 -1
- package/skills/tier0-sdk-v2/tier0-sdk-v2-notifications/SKILL.md +3 -3
- package/skills/tier0-sdk-v2/tier0-sdk-v2-notifications/references/send.md +6 -6
- package/skills/tier0-sdk-v2/tier0-sdk-v2-source/SKILL.md +4 -4
- package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/changes.md +2 -2
- package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/create.md +26 -36
- package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/delete.md +9 -23
- package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/protocols.md +12 -29
- package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/read.md +4 -5
- package/skills/tier0-sdk-v2/tier0-sdk-v2-source/references/update.md +5 -5
- package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/SKILL.md +3 -3
- package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/references/create.md +23 -9
- package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/references/deploy.md +14 -12
- package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/references/http-server.md +4 -4
- package/skills/tier0-sdk-v2/tier0-sdk-v2-stream/references/read.md +4 -3
- package/skills/tier0-sdk-v2/tier0-sdk-v2-streams/SKILL.md +12 -12
- package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/SKILL.md +3 -2
- package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/create.md +2 -2
- package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/delete.md +47 -20
- package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/namespace.md +3 -3
- package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/read.md +11 -6
- package/skills/tier0-sdk-v2/tier0-sdk-v2-uns/references/restore.md +12 -13
- package/dist/cjs/app-identity-contract.generated.d.ts +0 -22
- package/dist/cjs/app-identity-contract.generated.js +0 -51
- package/dist/cjs/app-identity-contract.generated.js.map +0 -1
- package/dist/cjs/runtime.d.ts +0 -13
- package/dist/cjs/runtime.js +0 -51
- package/dist/cjs/runtime.js.map +0 -1
- package/dist/esm/app-identity-contract.generated.d.ts +0 -22
- package/dist/esm/app-identity-contract.generated.js +0 -48
- package/dist/esm/app-identity-contract.generated.js.map +0 -1
- package/dist/esm/runtime.d.ts +0 -13
- package/dist/esm/runtime.js +0 -46
- package/dist/esm/runtime.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/data/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGhD,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEhE,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/data/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAGhD,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEhE,OAAO,EAAE,WAAW,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAC/D,OAAO,EAAE,yBAAyB,EAAE,MAAM,gBAAgB,CAAC;AAE3D,OAAO,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE/C,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AACvD,cAAc,mBAAmB,CAAC;AAClC,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC","sourcesContent":["/**\n * `@tier0/sdk-v2/data` — server-only App data layer over Tier0 UNS collections.\n *\n * import { createDataClient, defineCollection } from '@tier0/sdk-v2/data';\n *\n * const data = createDataClient({ collections: [workOrders] });\n * const open = await data.collection(workOrders).find({ status: 'OPEN' }, { limit: 50 }).toArray();\n *\n * Declarations are ensured in one batch on first access. Requires `zod` 4.\n * Client code imports declarations and types from `@tier0/sdk-v2/data/define`.\n */\nexport { createDataClient } from './client.js';\nexport { MAX_BATCH_QUERIES } from './client.js';\nexport type { BatchQueries, BatchResults, DataClient, DataClientConfig, SettledBatchResults, TransactAction, TransactResult, TransactResultItem, TransactStep } from './client.js';\nexport type { DataEnvironment, EnsureConflict, EnsureNote, EnsureResult, EnsureState, EnsureWarning, Logger } from './bootstrap.js';\nexport { MAX_ATOMIC_ITEMS, MAX_GET_IDS } from './collection.js';\nexport type { BatchQuery, BulkOperation, CappedCount, Collection, CollectionQueries, Cursor, DeepPartial, FoundPage, ValueCount, FindOneAndOptions, FindOptions, FindPageOptions, InsertOneOptions, Projection, Update, UpdateOptions, WithMeta } from './collection.js';\nexport { runDetached, runWithAbortSignal } from './context.js';\nexport { SUBSCRIBE_IDLE_TIMEOUT_MS } from './subscribe.js';\nexport type { RecordChange, SubscribeOptions, SubscriptionEvent } from './subscribe.js';\nexport { createChangeRelay } from './relay.js';\nexport type { ChangeRelay, ChangeRelayOptions, ChangeRelaySource, ChangeRelayStatus, ChangeRelayStreamOptions, RelayedChange } from './relay.js';\nexport { ACCESS_REASONS, DataError } from './error.js';\nexport * from './declarations.js';\nexport { toSortPairs } from './sort.js';\nexport type { Filter, Sort, SortDirection, SortPairs } from './sort.js';\n"]}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { RecordChange, SubscribeObserver, SubscribeOptions, SubscriptionEvent } from './subscribe.js';
|
|
2
|
+
/** `idle`: no upstream; `connecting`: opening it; `ready`: it streams; `unavailable`: it cannot connect and keeps retrying. */
|
|
3
|
+
export type ChangeRelayStatus = 'idle' | 'connecting' | 'ready' | 'unavailable';
|
|
4
|
+
export interface ChangeRelayOptions {
|
|
5
|
+
/** Collections of the upstream; every declared collection by default. */
|
|
6
|
+
collections?: SubscribeOptions['collections'];
|
|
7
|
+
/** Changes kept for replay to reconnecting browsers. Default 1000. */
|
|
8
|
+
bufferSize?: number;
|
|
9
|
+
/** Close the upstream this long after the last browser left. Default 30 s. */
|
|
10
|
+
idleCloseMs?: number;
|
|
11
|
+
/** Wait before reopening a failed upstream; doubles per consecutive failure. Default 5 s. */
|
|
12
|
+
restartIntervalMs?: number;
|
|
13
|
+
/** Default 5 minutes. */
|
|
14
|
+
maxRestartIntervalMs?: number;
|
|
15
|
+
/** Default 15 s. */
|
|
16
|
+
heartbeatMs?: number;
|
|
17
|
+
/** A browser stream ends after this long (±10 %). Default 10 minutes. */
|
|
18
|
+
maxStreamMs?: number;
|
|
19
|
+
/** Status changes; `error` is the failure behind `unavailable`. */
|
|
20
|
+
onStatus?: (status: ChangeRelayStatus, error?: unknown) => void;
|
|
21
|
+
logger?: Pick<Console, 'warn'>;
|
|
22
|
+
}
|
|
23
|
+
export interface ChangeRelayStreamOptions {
|
|
24
|
+
/** Whether this browser gets the change; every change when absent. */
|
|
25
|
+
filter?: (change: RecordChange) => boolean;
|
|
26
|
+
/** The browser's `Last-Event-ID` header. */
|
|
27
|
+
lastEventId?: string | null;
|
|
28
|
+
/** Ends the stream, e.g. `request.signal`. */
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
/** The `data` of a `change` frame. `eventId` is the relay's position, not a UNS cursor. */
|
|
32
|
+
export interface RelayedChange {
|
|
33
|
+
eventId: string;
|
|
34
|
+
collection: string;
|
|
35
|
+
change: 'record' | 'delete';
|
|
36
|
+
recordId: string;
|
|
37
|
+
revision: number;
|
|
38
|
+
occurredAt: number;
|
|
39
|
+
}
|
|
40
|
+
export interface ChangeRelay {
|
|
41
|
+
/** An SSE response for one browser. */
|
|
42
|
+
stream(options?: ChangeRelayStreamOptions): Response;
|
|
43
|
+
/** Open browser streams. */
|
|
44
|
+
readonly size: number;
|
|
45
|
+
readonly status: ChangeRelayStatus;
|
|
46
|
+
}
|
|
47
|
+
/** The upstream as the relay uses it: `data.subscribe` with its internal observer. */
|
|
48
|
+
export interface ChangeRelaySource {
|
|
49
|
+
subscribe(options: SubscribeOptions, observer?: SubscribeObserver): AsyncIterable<SubscriptionEvent>;
|
|
50
|
+
}
|
|
51
|
+
export declare function createChangeRelay(data: ChangeRelaySource, options?: ChangeRelayOptions): ChangeRelay;
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createChangeRelay`: one `data.subscribe` per server process, fanned out to
|
|
3
|
+
* browsers as server-sent events on a route of the App.
|
|
4
|
+
*
|
|
5
|
+
* const relay = createChangeRelay(data);
|
|
6
|
+
* // GET /api/changes, after the App authenticated the visitor:
|
|
7
|
+
* return relay.stream({
|
|
8
|
+
* filter: (change) => canSee(visitor, change),
|
|
9
|
+
* lastEventId: request.headers.get('Last-Event-ID'),
|
|
10
|
+
* signal: request.signal,
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* The upstream runs as the App and sees every change of the collections
|
|
14
|
+
* granted to it; `filter` decides, per browser, what that visitor may know
|
|
15
|
+
* about. It is called synchronously for every change, live or replayed, and
|
|
16
|
+
* a filter that throws drops the change. Events carry identities only
|
|
17
|
+
* (collection, record ID, revision, kind of change), never record data: the
|
|
18
|
+
* browser reads what it shows through the App's own API, which applies the
|
|
19
|
+
* App's rules.
|
|
20
|
+
*
|
|
21
|
+
* Browsers never open their own upstream: the Gateway limits open
|
|
22
|
+
* subscriptions per workspace, not per visitor.
|
|
23
|
+
*
|
|
24
|
+
* Positions: browsers never see UNS cursors. The relay numbers the changes it
|
|
25
|
+
* forwards `<epoch>.<seq>`; that is the SSE `id`. A browser gets a position
|
|
26
|
+
* (an id-only frame, repeated as its heartbeat) only once the upstream can
|
|
27
|
+
* deliver every change after it:
|
|
28
|
+
*
|
|
29
|
+
* - Cold start: until the upstream's first connection streams, browsers get
|
|
30
|
+
* no position. Then each of them gets `reset` (`upstream-started`) with a
|
|
31
|
+
* position, so a page that read its data before the upstream existed
|
|
32
|
+
* reads it again.
|
|
33
|
+
* - The relay keeps the last UNS position. When the upstream fails, or closes
|
|
34
|
+
* `idleCloseMs` after the last browser left, it reopens from that position
|
|
35
|
+
* in the same epoch: nothing is missed and connected browsers get no reset.
|
|
36
|
+
* - Only a `reset` from the upstream (`CURSOR_EXPIRED`, `SCOPE_CHANGED`,
|
|
37
|
+
* `COLLECTION_RECREATED`) starts a new epoch. Connected browsers then get
|
|
38
|
+
* that `reset`, with a new position, once the upstream streams again.
|
|
39
|
+
*
|
|
40
|
+
* A reconnect's `Last-Event-ID` is looked up in the latest `bufferSize`
|
|
41
|
+
* changes of the current epoch: found, the changes after it are replayed;
|
|
42
|
+
* anything else (another epoch, too old, forged) answers that browser with
|
|
43
|
+
* `reset` (`unknown-event-id`). `reset` means the same as from
|
|
44
|
+
* `data.subscribe`: changes may have been missed, reload what is shown.
|
|
45
|
+
*
|
|
46
|
+
* When the upstream cannot connect (e.g. 429 over the subscription limit,
|
|
47
|
+
* `APP_NOT_READY`, or a failure that ends `data.subscribe`), the relay goes to
|
|
48
|
+
* status `unavailable`: `onStatus` hears it, `logger.warn` logs it once, and
|
|
49
|
+
* every browser stream gets `event: unavailable` with `{reason}`. The upstream
|
|
50
|
+
* keeps retrying; when it streams again the status is `ready`, and browsers
|
|
51
|
+
* that were waiting for a position get it as described above. A failure that
|
|
52
|
+
* ends `data.subscribe` reopens after `restartIntervalMs`, doubling per
|
|
53
|
+
* consecutive failure up to `maxRestartIntervalMs`.
|
|
54
|
+
*
|
|
55
|
+
* Every browser stream ends after `maxStreamMs` (±10 %), so the browser
|
|
56
|
+
* reconnects and the App's route authenticates it again. Reconnect timing is
|
|
57
|
+
* the browser client's: `openEventStream` (`@tier0/sdk-v2/event-stream`)
|
|
58
|
+
* handles platform errors, the App's sleep and hidden pages itself, so the
|
|
59
|
+
* relay sends no `retry:` field.
|
|
60
|
+
*
|
|
61
|
+
* Frames: `event: change` with `{eventId, collection, change, recordId,
|
|
62
|
+
* revision, occurredAt}` (`RelayedChange`), `event: reset` with `{reason}`,
|
|
63
|
+
* `event: unavailable` with `{reason}`, and every `heartbeatMs` an id-only
|
|
64
|
+
* frame with the current position (a `: heartbeat` comment while the browser
|
|
65
|
+
* has none), so an idle browser's resume point stays inside the buffer.
|
|
66
|
+
*/
|
|
67
|
+
import { randomUUID } from 'node:crypto';
|
|
68
|
+
const DEFAULTS = {
|
|
69
|
+
bufferSize: 1000,
|
|
70
|
+
idleCloseMs: 30_000,
|
|
71
|
+
restartIntervalMs: 5_000,
|
|
72
|
+
maxRestartIntervalMs: 300_000,
|
|
73
|
+
heartbeatMs: 15_000,
|
|
74
|
+
maxStreamMs: 600_000,
|
|
75
|
+
};
|
|
76
|
+
function changeFrame(change, eventId) {
|
|
77
|
+
const { collection, change: kind, recordId, revision, occurredAt } = change;
|
|
78
|
+
const data = { eventId, collection, change: kind, recordId, revision, occurredAt };
|
|
79
|
+
return `event: change\nid: ${eventId}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
80
|
+
}
|
|
81
|
+
/** A reset that also moves the browser to `position`, so its next reconnect resumes there. */
|
|
82
|
+
function resetFrame(reason, position) {
|
|
83
|
+
return `event: reset\nid: ${position}\ndata: ${JSON.stringify({ reason })}\n\n`;
|
|
84
|
+
}
|
|
85
|
+
function unavailableFrame(reason) {
|
|
86
|
+
return `event: unavailable\ndata: ${JSON.stringify({ reason })}\n\n`;
|
|
87
|
+
}
|
|
88
|
+
function reasonOf(error) {
|
|
89
|
+
const value = error;
|
|
90
|
+
return typeof value?.code === 'string' ? value.code : 'upstream-failed';
|
|
91
|
+
}
|
|
92
|
+
function newSession() {
|
|
93
|
+
return { epoch: randomUUID().slice(0, 8), seq: 0, buffer: [] };
|
|
94
|
+
}
|
|
95
|
+
export function createChangeRelay(data, options = {}) {
|
|
96
|
+
const { collections, onStatus, logger = console } = options;
|
|
97
|
+
const { bufferSize, idleCloseMs, restartIntervalMs, maxRestartIntervalMs, heartbeatMs, maxStreamMs } = { ...DEFAULTS, ...options };
|
|
98
|
+
const subscribers = new Set();
|
|
99
|
+
let session = newSession();
|
|
100
|
+
/** The last UNS position: the upstream reopens from it. */
|
|
101
|
+
let cursor;
|
|
102
|
+
/** The current upstream connection streams. */
|
|
103
|
+
let live = false;
|
|
104
|
+
let status = 'idle';
|
|
105
|
+
let unavailableReason = 'upstream-failed';
|
|
106
|
+
let upstream = null;
|
|
107
|
+
let startTimer;
|
|
108
|
+
let idleTimer;
|
|
109
|
+
let failures = 0;
|
|
110
|
+
const position = () => `${session.epoch}.${session.seq}`;
|
|
111
|
+
/** Every change after the current position will reach the browsers. */
|
|
112
|
+
const positionable = () => cursor !== undefined || live;
|
|
113
|
+
function setStatus(next, error) {
|
|
114
|
+
if (next === status)
|
|
115
|
+
return;
|
|
116
|
+
status = next;
|
|
117
|
+
if (next === 'unavailable') {
|
|
118
|
+
unavailableReason = reasonOf(error);
|
|
119
|
+
logger.warn(`[change-relay] upstream unavailable (${unavailableReason}); retrying:`, error instanceof Error ? error.message : error);
|
|
120
|
+
for (const subscriber of subscribers)
|
|
121
|
+
subscriber.send(unavailableFrame(unavailableReason));
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
onStatus?.(next, error);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// A status listener cannot stop the relay.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Gives waiting browsers their reset and position, once the upstream can deliver what follows. */
|
|
131
|
+
function positionWaiting() {
|
|
132
|
+
if (!positionable())
|
|
133
|
+
return;
|
|
134
|
+
for (const subscriber of subscribers) {
|
|
135
|
+
if (subscriber.positioned)
|
|
136
|
+
continue;
|
|
137
|
+
subscriber.positioned = true;
|
|
138
|
+
subscriber.send(resetFrame(subscriber.resetReason, position()));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function forward(change) {
|
|
142
|
+
session.seq += 1;
|
|
143
|
+
session.buffer.push({ seq: session.seq, change });
|
|
144
|
+
if (session.buffer.length > bufferSize)
|
|
145
|
+
session.buffer.splice(0, session.buffer.length - bufferSize);
|
|
146
|
+
const chunk = changeFrame(change, position());
|
|
147
|
+
for (const subscriber of subscribers)
|
|
148
|
+
if (subscriber.positioned && subscriber.matches(change))
|
|
149
|
+
subscriber.send(chunk);
|
|
150
|
+
}
|
|
151
|
+
/** The upstream reset: a new epoch, and every browser reloads once the upstream streams again. */
|
|
152
|
+
function restartEpoch(reason) {
|
|
153
|
+
session = newSession();
|
|
154
|
+
cursor = undefined;
|
|
155
|
+
live = false;
|
|
156
|
+
for (const subscriber of subscribers) {
|
|
157
|
+
subscriber.positioned = false;
|
|
158
|
+
subscriber.resetReason = reason;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function run(controller) {
|
|
162
|
+
const observer = {
|
|
163
|
+
connected() {
|
|
164
|
+
if (controller.signal.aborted)
|
|
165
|
+
return;
|
|
166
|
+
live = true;
|
|
167
|
+
failures = 0;
|
|
168
|
+
setStatus('ready');
|
|
169
|
+
positionWaiting();
|
|
170
|
+
},
|
|
171
|
+
position(eventId) {
|
|
172
|
+
if (!controller.signal.aborted)
|
|
173
|
+
cursor = eventId;
|
|
174
|
+
},
|
|
175
|
+
retrying(error) {
|
|
176
|
+
if (controller.signal.aborted)
|
|
177
|
+
return;
|
|
178
|
+
live = false;
|
|
179
|
+
setStatus('unavailable', error);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
live = false;
|
|
183
|
+
setStatus('connecting');
|
|
184
|
+
try {
|
|
185
|
+
const subscription = data.subscribe({
|
|
186
|
+
...(collections ? { collections } : {}),
|
|
187
|
+
...(cursor === undefined ? {} : { lastEventId: cursor }),
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
}, observer);
|
|
190
|
+
for await (const event of subscription) {
|
|
191
|
+
if (controller.signal.aborted)
|
|
192
|
+
break;
|
|
193
|
+
if (event.type === 'reset')
|
|
194
|
+
restartEpoch(event.reason);
|
|
195
|
+
else
|
|
196
|
+
forward(event);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (!controller.signal.aborted) {
|
|
201
|
+
failures += 1;
|
|
202
|
+
live = false;
|
|
203
|
+
setStatus('unavailable', error);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
if (upstream === controller)
|
|
208
|
+
upstream = null;
|
|
209
|
+
live = false;
|
|
210
|
+
// Reopens from the last UNS position: same epoch, nothing missed.
|
|
211
|
+
if (!controller.signal.aborted)
|
|
212
|
+
schedule();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function start() {
|
|
216
|
+
startTimer = undefined;
|
|
217
|
+
if (upstream || subscribers.size === 0)
|
|
218
|
+
return;
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
upstream = controller;
|
|
221
|
+
// The upstream outlives the request that started it; data.subscribe never takes a request's signal.
|
|
222
|
+
void run(controller);
|
|
223
|
+
}
|
|
224
|
+
function schedule() {
|
|
225
|
+
if (upstream || startTimer !== undefined || subscribers.size === 0)
|
|
226
|
+
return;
|
|
227
|
+
const delay = failures === 0 ? 0 : Math.min(maxRestartIntervalMs, restartIntervalMs * 2 ** (failures - 1));
|
|
228
|
+
startTimer = setTimeout(start, delay);
|
|
229
|
+
startTimer.unref?.();
|
|
230
|
+
}
|
|
231
|
+
function join(subscriber) {
|
|
232
|
+
if (idleTimer !== undefined) {
|
|
233
|
+
clearTimeout(idleTimer);
|
|
234
|
+
idleTimer = undefined;
|
|
235
|
+
}
|
|
236
|
+
subscribers.add(subscriber);
|
|
237
|
+
schedule();
|
|
238
|
+
}
|
|
239
|
+
function release(subscriber) {
|
|
240
|
+
if (!subscribers.delete(subscriber) || subscribers.size > 0)
|
|
241
|
+
return;
|
|
242
|
+
if (startTimer !== undefined) {
|
|
243
|
+
clearTimeout(startTimer);
|
|
244
|
+
startTimer = undefined;
|
|
245
|
+
}
|
|
246
|
+
idleTimer = setTimeout(() => {
|
|
247
|
+
idleTimer = undefined;
|
|
248
|
+
// The epoch, buffer and UNS position stay: the next browser resumes in the same epoch.
|
|
249
|
+
upstream?.abort();
|
|
250
|
+
upstream = null;
|
|
251
|
+
live = false;
|
|
252
|
+
setStatus('idle');
|
|
253
|
+
}, idleCloseMs);
|
|
254
|
+
idleTimer.unref?.();
|
|
255
|
+
}
|
|
256
|
+
/** Replays after `lastEventId`; false when it is not a position of the current epoch's buffer. */
|
|
257
|
+
function catchUp(subscriber, lastEventId) {
|
|
258
|
+
if (!lastEventId)
|
|
259
|
+
return false;
|
|
260
|
+
const [epoch, seqText] = lastEventId.split('.');
|
|
261
|
+
const seq = Number(seqText);
|
|
262
|
+
const oldest = session.buffer[0]?.seq ?? session.seq + 1;
|
|
263
|
+
if (epoch !== session.epoch || !Number.isInteger(seq) || seq > session.seq || seq < oldest - 1)
|
|
264
|
+
return false;
|
|
265
|
+
for (const entry of session.buffer) {
|
|
266
|
+
if (entry.seq > seq && subscriber.matches(entry.change))
|
|
267
|
+
subscriber.send(changeFrame(entry.change, `${session.epoch}.${entry.seq}`));
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
get size() {
|
|
273
|
+
return subscribers.size;
|
|
274
|
+
},
|
|
275
|
+
get status() {
|
|
276
|
+
return status;
|
|
277
|
+
},
|
|
278
|
+
stream({ filter, lastEventId, signal } = {}) {
|
|
279
|
+
const matches = (change) => {
|
|
280
|
+
if (!filter)
|
|
281
|
+
return true;
|
|
282
|
+
try {
|
|
283
|
+
return filter(change) === true;
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
logger.warn('[change-relay] filter failed; change dropped:', error instanceof Error ? error.message : error);
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const encoder = new TextEncoder();
|
|
291
|
+
let subscriber;
|
|
292
|
+
let heartbeat;
|
|
293
|
+
let expiry;
|
|
294
|
+
let close = () => undefined;
|
|
295
|
+
const body = new ReadableStream({
|
|
296
|
+
start(controller) {
|
|
297
|
+
let closed = false;
|
|
298
|
+
close = () => {
|
|
299
|
+
if (closed)
|
|
300
|
+
return;
|
|
301
|
+
closed = true;
|
|
302
|
+
clearInterval(heartbeat);
|
|
303
|
+
clearTimeout(expiry);
|
|
304
|
+
signal?.removeEventListener('abort', close);
|
|
305
|
+
if (subscriber)
|
|
306
|
+
release(subscriber);
|
|
307
|
+
try {
|
|
308
|
+
controller.close();
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// Already closed by the consumer.
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
const send = (chunk) => {
|
|
315
|
+
if (closed)
|
|
316
|
+
return;
|
|
317
|
+
try {
|
|
318
|
+
controller.enqueue(encoder.encode(chunk));
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
close();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
if (signal?.aborted) {
|
|
325
|
+
closed = true;
|
|
326
|
+
controller.close();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const current = { matches, send, positioned: false, resetReason: 'upstream-started' };
|
|
330
|
+
subscriber = current;
|
|
331
|
+
if (positionable()) {
|
|
332
|
+
current.positioned = true;
|
|
333
|
+
// A known position replays what followed; anything else reloads.
|
|
334
|
+
if (catchUp(current, lastEventId ?? undefined))
|
|
335
|
+
send(`id: ${position()}\n\n`);
|
|
336
|
+
else if (lastEventId)
|
|
337
|
+
send(resetFrame('unknown-event-id', position()));
|
|
338
|
+
// A first connect: an id-only frame dispatches nothing and gives the browser its resume point.
|
|
339
|
+
else
|
|
340
|
+
send(`id: ${position()}\n\n`);
|
|
341
|
+
}
|
|
342
|
+
else if (lastEventId) {
|
|
343
|
+
current.resetReason = 'unknown-event-id';
|
|
344
|
+
}
|
|
345
|
+
if (status === 'unavailable')
|
|
346
|
+
send(unavailableFrame(unavailableReason));
|
|
347
|
+
join(current);
|
|
348
|
+
heartbeat = setInterval(() => send(current.positioned ? `id: ${position()}\n\n` : ': heartbeat\n\n'), heartbeatMs);
|
|
349
|
+
heartbeat.unref?.();
|
|
350
|
+
expiry = setTimeout(close, maxStreamMs * (0.9 + Math.random() * 0.2));
|
|
351
|
+
expiry.unref?.();
|
|
352
|
+
signal?.addEventListener('abort', close, { once: true });
|
|
353
|
+
},
|
|
354
|
+
cancel() {
|
|
355
|
+
close();
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
return new Response(body, {
|
|
359
|
+
headers: {
|
|
360
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
361
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
362
|
+
'X-Accel-Buffering': 'no',
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
//# sourceMappingURL=relay.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay.js","sourceRoot":"","sources":["../../../src/data/relay.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `createChangeRelay`: one `data.subscribe` per server process, fanned out to\n * browsers as server-sent events on a route of the App.\n *\n * const relay = createChangeRelay(data);\n * // GET /api/changes, after the App authenticated the visitor:\n * return relay.stream({\n * filter: (change) => canSee(visitor, change),\n * lastEventId: request.headers.get('Last-Event-ID'),\n * signal: request.signal,\n * });\n *\n * The upstream runs as the App and sees every change of the collections\n * granted to it; `filter` decides, per browser, what that visitor may know\n * about. It is called synchronously for every change, live or replayed, and\n * a filter that throws drops the change. Events carry identities only\n * (collection, record ID, revision, kind of change), never record data: the\n * browser reads what it shows through the App's own API, which applies the\n * App's rules.\n *\n * Browsers never open their own upstream: the Gateway limits open\n * subscriptions per workspace, not per visitor.\n *\n * Positions: browsers never see UNS cursors. The relay numbers the changes it\n * forwards `<epoch>.<seq>`; that is the SSE `id`. A browser gets a position\n * (an id-only frame, repeated as its heartbeat) only once the upstream can\n * deliver every change after it:\n *\n * - Cold start: until the upstream's first connection streams, browsers get\n * no position. Then each of them gets `reset` (`upstream-started`) with a\n * position, so a page that read its data before the upstream existed\n * reads it again.\n * - The relay keeps the last UNS position. When the upstream fails, or closes\n * `idleCloseMs` after the last browser left, it reopens from that position\n * in the same epoch: nothing is missed and connected browsers get no reset.\n * - Only a `reset` from the upstream (`CURSOR_EXPIRED`, `SCOPE_CHANGED`,\n * `COLLECTION_RECREATED`) starts a new epoch. Connected browsers then get\n * that `reset`, with a new position, once the upstream streams again.\n *\n * A reconnect's `Last-Event-ID` is looked up in the latest `bufferSize`\n * changes of the current epoch: found, the changes after it are replayed;\n * anything else (another epoch, too old, forged) answers that browser with\n * `reset` (`unknown-event-id`). `reset` means the same as from\n * `data.subscribe`: changes may have been missed, reload what is shown.\n *\n * When the upstream cannot connect (e.g. 429 over the subscription limit,\n * `APP_NOT_READY`, or a failure that ends `data.subscribe`), the relay goes to\n * status `unavailable`: `onStatus` hears it, `logger.warn` logs it once, and\n * every browser stream gets `event: unavailable` with `{reason}`. The upstream\n * keeps retrying; when it streams again the status is `ready`, and browsers\n * that were waiting for a position get it as described above. A failure that\n * ends `data.subscribe` reopens after `restartIntervalMs`, doubling per\n * consecutive failure up to `maxRestartIntervalMs`.\n *\n * Every browser stream ends after `maxStreamMs` (±10 %), so the browser\n * reconnects and the App's route authenticates it again. Reconnect timing is\n * the browser client's: `openEventStream` (`@tier0/sdk-v2/event-stream`)\n * handles platform errors, the App's sleep and hidden pages itself, so the\n * relay sends no `retry:` field.\n *\n * Frames: `event: change` with `{eventId, collection, change, recordId,\n * revision, occurredAt}` (`RelayedChange`), `event: reset` with `{reason}`,\n * `event: unavailable` with `{reason}`, and every `heartbeatMs` an id-only\n * frame with the current position (a `: heartbeat` comment while the browser\n * has none), so an idle browser's resume point stays inside the buffer.\n */\nimport { randomUUID } from 'node:crypto';\nimport type { DataError } from './error.js';\nimport type { RecordChange, SubscribeObserver, SubscribeOptions, SubscriptionEvent } from './subscribe.js';\n\n/** `idle`: no upstream; `connecting`: opening it; `ready`: it streams; `unavailable`: it cannot connect and keeps retrying. */\nexport type ChangeRelayStatus = 'idle' | 'connecting' | 'ready' | 'unavailable';\n\nexport interface ChangeRelayOptions {\n /** Collections of the upstream; every declared collection by default. */\n collections?: SubscribeOptions['collections'];\n /** Changes kept for replay to reconnecting browsers. Default 1000. */\n bufferSize?: number;\n /** Close the upstream this long after the last browser left. Default 30 s. */\n idleCloseMs?: number;\n /** Wait before reopening a failed upstream; doubles per consecutive failure. Default 5 s. */\n restartIntervalMs?: number;\n /** Default 5 minutes. */\n maxRestartIntervalMs?: number;\n /** Default 15 s. */\n heartbeatMs?: number;\n /** A browser stream ends after this long (±10 %). Default 10 minutes. */\n maxStreamMs?: number;\n /** Status changes; `error` is the failure behind `unavailable`. */\n onStatus?: (status: ChangeRelayStatus, error?: unknown) => void;\n logger?: Pick<Console, 'warn'>;\n}\n\nexport interface ChangeRelayStreamOptions {\n /** Whether this browser gets the change; every change when absent. */\n filter?: (change: RecordChange) => boolean;\n /** The browser's `Last-Event-ID` header. */\n lastEventId?: string | null;\n /** Ends the stream, e.g. `request.signal`. */\n signal?: AbortSignal;\n}\n\n/** The `data` of a `change` frame. `eventId` is the relay's position, not a UNS cursor. */\nexport interface RelayedChange {\n eventId: string;\n collection: string;\n change: 'record' | 'delete';\n recordId: string;\n revision: number;\n occurredAt: number;\n}\n\nexport interface ChangeRelay {\n /** An SSE response for one browser. */\n stream(options?: ChangeRelayStreamOptions): Response;\n /** Open browser streams. */\n readonly size: number;\n readonly status: ChangeRelayStatus;\n}\n\n/** The upstream as the relay uses it: `data.subscribe` with its internal observer. */\nexport interface ChangeRelaySource {\n subscribe(options: SubscribeOptions, observer?: SubscribeObserver): AsyncIterable<SubscriptionEvent>;\n}\n\nconst DEFAULTS = {\n bufferSize: 1000,\n idleCloseMs: 30_000,\n restartIntervalMs: 5_000,\n maxRestartIntervalMs: 300_000,\n heartbeatMs: 15_000,\n maxStreamMs: 600_000,\n};\n\ninterface Subscriber {\n matches(change: RecordChange): boolean;\n send(chunk: string): void;\n /** Has a position; until then it gets no change frames and, once positioned, a reset with `resetReason`. */\n positioned: boolean;\n resetReason: string;\n}\n\n/** One epoch's numbering of the changes the relay forwarded. */\ninterface Session {\n epoch: string;\n seq: number;\n /** Latest forwarded changes, oldest first. */\n buffer: { seq: number; change: RecordChange }[];\n}\n\nfunction changeFrame(change: RecordChange, eventId: string): string {\n const { collection, change: kind, recordId, revision, occurredAt } = change;\n const data: RelayedChange = { eventId, collection, change: kind, recordId, revision, occurredAt };\n return `event: change\\nid: ${eventId}\\ndata: ${JSON.stringify(data)}\\n\\n`;\n}\n\n/** A reset that also moves the browser to `position`, so its next reconnect resumes there. */\nfunction resetFrame(reason: string, position: string): string {\n return `event: reset\\nid: ${position}\\ndata: ${JSON.stringify({ reason })}\\n\\n`;\n}\n\nfunction unavailableFrame(reason: string): string {\n return `event: unavailable\\ndata: ${JSON.stringify({ reason })}\\n\\n`;\n}\n\nfunction reasonOf(error: unknown): string {\n const value = error as Partial<DataError> | undefined;\n return typeof value?.code === 'string' ? value.code : 'upstream-failed';\n}\n\nfunction newSession(): Session {\n return { epoch: randomUUID().slice(0, 8), seq: 0, buffer: [] };\n}\n\nexport function createChangeRelay(data: ChangeRelaySource, options: ChangeRelayOptions = {}): ChangeRelay {\n const { collections, onStatus, logger = console } = options;\n const { bufferSize, idleCloseMs, restartIntervalMs, maxRestartIntervalMs, heartbeatMs, maxStreamMs } = { ...DEFAULTS, ...options };\n const subscribers = new Set<Subscriber>();\n let session = newSession();\n /** The last UNS position: the upstream reopens from it. */\n let cursor: string | undefined;\n /** The current upstream connection streams. */\n let live = false;\n let status: ChangeRelayStatus = 'idle';\n let unavailableReason = 'upstream-failed';\n let upstream: AbortController | null = null;\n let startTimer: ReturnType<typeof setTimeout> | undefined;\n let idleTimer: ReturnType<typeof setTimeout> | undefined;\n let failures = 0;\n\n const position = () => `${session.epoch}.${session.seq}`;\n /** Every change after the current position will reach the browsers. */\n const positionable = () => cursor !== undefined || live;\n\n function setStatus(next: ChangeRelayStatus, error?: unknown) {\n if (next === status) return;\n status = next;\n if (next === 'unavailable') {\n unavailableReason = reasonOf(error);\n logger.warn(`[change-relay] upstream unavailable (${unavailableReason}); retrying:`, error instanceof Error ? error.message : error);\n for (const subscriber of subscribers) subscriber.send(unavailableFrame(unavailableReason));\n }\n try {\n onStatus?.(next, error);\n } catch {\n // A status listener cannot stop the relay.\n }\n }\n\n /** Gives waiting browsers their reset and position, once the upstream can deliver what follows. */\n function positionWaiting() {\n if (!positionable()) return;\n for (const subscriber of subscribers) {\n if (subscriber.positioned) continue;\n subscriber.positioned = true;\n subscriber.send(resetFrame(subscriber.resetReason, position()));\n }\n }\n\n function forward(change: RecordChange) {\n session.seq += 1;\n session.buffer.push({ seq: session.seq, change });\n if (session.buffer.length > bufferSize) session.buffer.splice(0, session.buffer.length - bufferSize);\n const chunk = changeFrame(change, position());\n for (const subscriber of subscribers) if (subscriber.positioned && subscriber.matches(change)) subscriber.send(chunk);\n }\n\n /** The upstream reset: a new epoch, and every browser reloads once the upstream streams again. */\n function restartEpoch(reason: string) {\n session = newSession();\n cursor = undefined;\n live = false;\n for (const subscriber of subscribers) {\n subscriber.positioned = false;\n subscriber.resetReason = reason;\n }\n }\n\n async function run(controller: AbortController) {\n const observer: SubscribeObserver = {\n connected() {\n if (controller.signal.aborted) return;\n live = true;\n failures = 0;\n setStatus('ready');\n positionWaiting();\n },\n position(eventId) {\n if (!controller.signal.aborted) cursor = eventId;\n },\n retrying(error) {\n if (controller.signal.aborted) return;\n live = false;\n setStatus('unavailable', error);\n },\n };\n live = false;\n setStatus('connecting');\n try {\n const subscription = data.subscribe({\n ...(collections ? { collections } : {}),\n ...(cursor === undefined ? {} : { lastEventId: cursor }),\n signal: controller.signal,\n }, observer);\n for await (const event of subscription) {\n if (controller.signal.aborted) break;\n if (event.type === 'reset') restartEpoch(event.reason);\n else forward(event);\n }\n } catch (error) {\n if (!controller.signal.aborted) {\n failures += 1;\n live = false;\n setStatus('unavailable', error);\n }\n } finally {\n if (upstream === controller) upstream = null;\n live = false;\n // Reopens from the last UNS position: same epoch, nothing missed.\n if (!controller.signal.aborted) schedule();\n }\n }\n\n function start() {\n startTimer = undefined;\n if (upstream || subscribers.size === 0) return;\n const controller = new AbortController();\n upstream = controller;\n // The upstream outlives the request that started it; data.subscribe never takes a request's signal.\n void run(controller);\n }\n\n function schedule() {\n if (upstream || startTimer !== undefined || subscribers.size === 0) return;\n const delay = failures === 0 ? 0 : Math.min(maxRestartIntervalMs, restartIntervalMs * 2 ** (failures - 1));\n startTimer = setTimeout(start, delay);\n startTimer.unref?.();\n }\n\n function join(subscriber: Subscriber) {\n if (idleTimer !== undefined) {\n clearTimeout(idleTimer);\n idleTimer = undefined;\n }\n subscribers.add(subscriber);\n schedule();\n }\n\n function release(subscriber: Subscriber) {\n if (!subscribers.delete(subscriber) || subscribers.size > 0) return;\n if (startTimer !== undefined) {\n clearTimeout(startTimer);\n startTimer = undefined;\n }\n idleTimer = setTimeout(() => {\n idleTimer = undefined;\n // The epoch, buffer and UNS position stay: the next browser resumes in the same epoch.\n upstream?.abort();\n upstream = null;\n live = false;\n setStatus('idle');\n }, idleCloseMs);\n idleTimer.unref?.();\n }\n\n /** Replays after `lastEventId`; false when it is not a position of the current epoch's buffer. */\n function catchUp(subscriber: Subscriber, lastEventId: string | undefined): boolean {\n if (!lastEventId) return false;\n const [epoch, seqText] = lastEventId.split('.');\n const seq = Number(seqText);\n const oldest = session.buffer[0]?.seq ?? session.seq + 1;\n if (epoch !== session.epoch || !Number.isInteger(seq) || seq > session.seq || seq < oldest - 1) return false;\n for (const entry of session.buffer) {\n if (entry.seq > seq && subscriber.matches(entry.change)) subscriber.send(changeFrame(entry.change, `${session.epoch}.${entry.seq}`));\n }\n return true;\n }\n\n return {\n get size() {\n return subscribers.size;\n },\n get status() {\n return status;\n },\n stream({ filter, lastEventId, signal } = {}) {\n const matches = (change: RecordChange) => {\n if (!filter) return true;\n try {\n return filter(change) === true;\n } catch (error) {\n logger.warn('[change-relay] filter failed; change dropped:', error instanceof Error ? error.message : error);\n return false;\n }\n };\n const encoder = new TextEncoder();\n let subscriber: Subscriber | undefined;\n let heartbeat: ReturnType<typeof setInterval> | undefined;\n let expiry: ReturnType<typeof setTimeout> | undefined;\n let close = () => undefined as void;\n\n const body = new ReadableStream<Uint8Array>({\n start(controller) {\n let closed = false;\n close = () => {\n if (closed) return;\n closed = true;\n clearInterval(heartbeat);\n clearTimeout(expiry);\n signal?.removeEventListener('abort', close);\n if (subscriber) release(subscriber);\n try {\n controller.close();\n } catch {\n // Already closed by the consumer.\n }\n };\n const send = (chunk: string) => {\n if (closed) return;\n try {\n controller.enqueue(encoder.encode(chunk));\n } catch {\n close();\n }\n };\n if (signal?.aborted) {\n closed = true;\n controller.close();\n return;\n }\n const current: Subscriber = { matches, send, positioned: false, resetReason: 'upstream-started' };\n subscriber = current;\n if (positionable()) {\n current.positioned = true;\n // A known position replays what followed; anything else reloads.\n if (catchUp(current, lastEventId ?? undefined)) send(`id: ${position()}\\n\\n`);\n else if (lastEventId) send(resetFrame('unknown-event-id', position()));\n // A first connect: an id-only frame dispatches nothing and gives the browser its resume point.\n else send(`id: ${position()}\\n\\n`);\n } else if (lastEventId) {\n current.resetReason = 'unknown-event-id';\n }\n if (status === 'unavailable') send(unavailableFrame(unavailableReason));\n join(current);\n heartbeat = setInterval(() => send(current.positioned ? `id: ${position()}\\n\\n` : ': heartbeat\\n\\n'), heartbeatMs);\n heartbeat.unref?.();\n expiry = setTimeout(close, maxStreamMs * (0.9 + Math.random() * 0.2));\n expiry.unref?.();\n signal?.addEventListener('abort', close, { once: true });\n },\n cancel() {\n close();\n },\n });\n\n return new Response(body, {\n headers: {\n 'Content-Type': 'text/event-stream; charset=utf-8',\n 'Cache-Control': 'no-cache, no-transform',\n 'X-Accel-Buffering': 'no',\n },\n });\n },\n };\n}\n"]}
|
|
@@ -23,9 +23,11 @@
|
|
|
23
23
|
* `delete` always passes, a late one too, and forgets the record's
|
|
24
24
|
* revision, since a record written again under the same ID may start
|
|
25
25
|
* over at revision 1.
|
|
26
|
-
* - Changes in collections with employee data rules (`restricted`) come as
|
|
27
|
-
* collection-level notices unless `restricted: "record"` is asked for.
|
|
28
26
|
* - Aborting `signal` or leaving the loop ends the iteration without error.
|
|
27
|
+
* - The stream runs as the App: it sees every change of the collections
|
|
28
|
+
* granted to the App. A server opens one per process and fans it out to
|
|
29
|
+
* browsers with `createChangeRelay`, filtering per browser by what the App
|
|
30
|
+
* lets that visitor see.
|
|
29
31
|
*
|
|
30
32
|
* Events carry identities only, never record data; read the record to show it.
|
|
31
33
|
*/
|
|
@@ -40,62 +42,46 @@ export interface SubscribeOptions {
|
|
|
40
42
|
collections?: readonly (CollectionTarget | string)[];
|
|
41
43
|
/** Only changes of these record IDs (1–100, duplicates ignored), when exactly one collection is watched. */
|
|
42
44
|
recordIds?: readonly string[];
|
|
43
|
-
/**
|
|
44
|
-
* How changes in collections with employee data rules arrive.
|
|
45
|
-
* `collection` (default): as a collection-level notice without record ID
|
|
46
|
-
* or revision, safe to forward to any employee. `record`: the full event,
|
|
47
|
-
* for server-side consumers that do not forward it to browsers, or that
|
|
48
|
-
* redact per recipient with `redactRestricted`.
|
|
49
|
-
*/
|
|
50
|
-
restricted?: 'collection' | 'record';
|
|
51
45
|
/** Resume after this event (e.g. a browser's `Last-Event-ID`). */
|
|
52
46
|
lastEventId?: string;
|
|
53
47
|
signal?: AbortSignal;
|
|
54
48
|
/** Reconnect when nothing, not even a heartbeat, arrives for this long. */
|
|
55
49
|
idleTimeoutMs?: number;
|
|
56
50
|
}
|
|
57
|
-
|
|
51
|
+
/**
|
|
52
|
+
* One record committed (`record`) or deleted (`delete`) at `revision`. A
|
|
53
|
+
* `record` change is never at or below a revision already passed on for the
|
|
54
|
+
* same record since its last delete; a `delete` may be.
|
|
55
|
+
*/
|
|
56
|
+
export interface RecordChange {
|
|
58
57
|
type: 'change';
|
|
59
58
|
eventId: string;
|
|
60
59
|
/** Alias of a declared collection, or the ID of an existing collection reference. */
|
|
61
60
|
collection: string;
|
|
62
61
|
collectionId: string;
|
|
63
62
|
occurredAt: number;
|
|
64
|
-
/**
|
|
65
|
-
* The collection has employee data rules: whether an employee may see this
|
|
66
|
-
* record depends on who they are. Such changes arrive as `CollectionChange`
|
|
67
|
-
* unless the subscription asked for `restricted: "record"`.
|
|
68
|
-
*/
|
|
69
|
-
restricted: boolean;
|
|
70
|
-
}
|
|
71
|
-
/**
|
|
72
|
-
* One record committed (`record`) or deleted (`delete`) at `revision`. A
|
|
73
|
-
* `record` change is never at or below a revision already passed on for the
|
|
74
|
-
* same record since its last delete; a `delete` may be.
|
|
75
|
-
*/
|
|
76
|
-
export interface RecordChange extends ChangeBase {
|
|
77
63
|
change: 'record' | 'delete';
|
|
78
64
|
recordId: string;
|
|
79
65
|
revision: number;
|
|
80
66
|
}
|
|
81
|
-
/** Something in the collection changed; which record is not disclosed. Reload through the data rules. */
|
|
82
|
-
export interface CollectionChange extends ChangeBase {
|
|
83
|
-
change: 'collection';
|
|
84
|
-
}
|
|
85
|
-
export type ChangeEvent = RecordChange | CollectionChange;
|
|
86
67
|
/** `reason`: `CURSOR_EXPIRED`, `SCOPE_CHANGED` (from UNS) or `COLLECTION_RECREATED`. */
|
|
87
|
-
export type SubscriptionEvent =
|
|
68
|
+
export type SubscriptionEvent = RecordChange | {
|
|
88
69
|
type: 'reset';
|
|
89
70
|
reason: string;
|
|
90
71
|
message?: string;
|
|
91
72
|
};
|
|
92
73
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* ID or revision, so it does not reveal records the employee may not read.
|
|
96
|
-
* Other events pass unchanged.
|
|
74
|
+
* What `data.subscribe` tells a server-side consumer about the upstream
|
|
75
|
+
* (`createChangeRelay` uses it; not part of the public API).
|
|
97
76
|
*/
|
|
98
|
-
export
|
|
77
|
+
export interface SubscribeObserver {
|
|
78
|
+
/** A connection is streaming: its first frame (the connect heartbeat) arrived. */
|
|
79
|
+
connected?(): void;
|
|
80
|
+
/** The resume position moved: a heartbeat position or a change, dropped or not. */
|
|
81
|
+
position?(eventId: string): void;
|
|
82
|
+
/** An attempt failed and will be retried after a wait (e.g. 429 over the subscription limit, `APP_NOT_READY`). */
|
|
83
|
+
retrying?(error: DataError): void;
|
|
84
|
+
}
|
|
99
85
|
export interface SubscribeRuntime {
|
|
100
86
|
openStream: DataOpenStream;
|
|
101
87
|
ensure(targets: readonly CollectionTarget[]): Promise<EnsureState>;
|
|
@@ -103,7 +89,5 @@ export interface SubscribeRuntime {
|
|
|
103
89
|
resolve(target: CollectionTarget): Promise<string>;
|
|
104
90
|
lookup(target: CollectionTarget | string): CollectionTarget;
|
|
105
91
|
declared: readonly CollectionTarget[];
|
|
106
|
-
subject(): string | undefined;
|
|
107
92
|
}
|
|
108
|
-
export declare function createSubscribe(runtime: SubscribeRuntime): (options?: SubscribeOptions) => AsyncGenerator<SubscriptionEvent, void, undefined>;
|
|
109
|
-
export {};
|
|
93
|
+
export declare function createSubscribe(runtime: SubscribeRuntime): (options?: SubscribeOptions, observer?: SubscribeObserver) => AsyncGenerator<SubscriptionEvent, void, undefined>;
|