@steve31415/baselib 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/dist/app-update/index.d.ts +2 -0
- package/dist/app-update/index.js +2 -0
- package/dist/app-update/retained-assets.d.ts +21 -0
- package/dist/app-update/retained-assets.js +51 -0
- package/dist/app-update/shell.d.ts +16 -0
- package/dist/app-update/shell.js +35 -0
- package/dist/app-update-browser/build-update.d.ts +52 -0
- package/dist/app-update-browser/build-update.js +161 -0
- package/dist/app-update-browser/index.d.ts +2 -0
- package/dist/app-update-browser/index.js +2 -0
- package/dist/app-update-browser/reload-safety.d.ts +8 -0
- package/dist/app-update-browser/reload-safety.js +23 -0
- package/dist/sync/broadcaster.d.ts +9 -0
- package/dist/sync/broadcaster.js +47 -0
- package/dist/sync/engine.d.ts +55 -0
- package/dist/sync/engine.js +197 -0
- package/dist/sync/index.d.ts +5 -0
- package/dist/sync/index.js +5 -0
- package/dist/sync/schema.d.ts +13 -0
- package/dist/sync/schema.js +24 -0
- package/dist/sync/session.d.ts +29 -0
- package/dist/sync/session.js +189 -0
- package/dist/sync/types.d.ts +79 -0
- package/dist/sync/types.js +8 -0
- package/dist/sync-browser/client.d.ts +187 -0
- package/dist/sync-browser/client.js +832 -0
- package/dist/sync-browser/index.d.ts +2 -0
- package/dist/sync-browser/index.js +2 -0
- package/dist/sync-browser/outbox.d.ts +47 -0
- package/dist/sync-browser/outbox.js +317 -0
- package/package.json +20 -3
package/README.md
CHANGED
|
@@ -5,8 +5,19 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
|
|
|
5
5
|
`docs/IMPL.md`. Full design rationale with alternatives:
|
|
6
6
|
`~/migration/research/base-services-design.md` (step 5).
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
`
|
|
8
|
+
Server subpath exports: `config`, `log`, `auth`, `s2s`, `db`, `http`, `sync`,
|
|
9
|
+
and `app-update`. Browser exports: `log-browser`, `rum`, `sync-browser`, and
|
|
10
|
+
`app-update-browser`.
|
|
11
|
+
|
|
12
|
+
`sync` provides the Postgres event-log and server protocol primitives;
|
|
13
|
+
`sync-browser` provides the IndexedDB outbox and offline-first client.
|
|
14
|
+
`app-update` renders build-aware shells, matches their ETags, and fetches
|
|
15
|
+
retained assets for consumer-owned routes;
|
|
16
|
+
`app-update-browser` detects a new traffic build and reloads when the app's
|
|
17
|
+
injected safety policy permits. Apps inject their domain mutations, state
|
|
18
|
+
adapter, transport/auth-state integration, and update hooks. These modules do
|
|
19
|
+
not decide authentication or ownership, contain app domain logic, or act as a
|
|
20
|
+
generic Yjs or service-worker coordinator.
|
|
10
21
|
|
|
11
22
|
Bins: `check-test-owners` — the fleet's structural test-coverage gate; every
|
|
12
23
|
app runs it from `npm run verify`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface AuthenticatedRequester {
|
|
2
|
+
request(options: {
|
|
3
|
+
url: string;
|
|
4
|
+
responseType: 'arraybuffer';
|
|
5
|
+
}): Promise<{
|
|
6
|
+
data: ArrayBuffer;
|
|
7
|
+
}>;
|
|
8
|
+
}
|
|
9
|
+
export interface RetainedAssetStoreOptions {
|
|
10
|
+
bucketName: string;
|
|
11
|
+
objectPrefix: string;
|
|
12
|
+
requester?: AuthenticatedRequester;
|
|
13
|
+
}
|
|
14
|
+
export declare class RetainedAssetStore {
|
|
15
|
+
private readonly bucketName;
|
|
16
|
+
private readonly objectPrefix;
|
|
17
|
+
private readonly requester;
|
|
18
|
+
constructor(options: RetainedAssetStoreOptions);
|
|
19
|
+
get(assetPath: string): Promise<Uint8Array | null>;
|
|
20
|
+
}
|
|
21
|
+
export declare function assetContentType(path: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Private GCS archive for hashed assets from prior builds. Current-build
|
|
2
|
+
// assets stay on the revision-local fast path; only a local 404 reaches this
|
|
3
|
+
// store. Consumer HTTP routes remain authenticated and same-origin.
|
|
4
|
+
import { GoogleAuth } from 'google-auth-library';
|
|
5
|
+
export class RetainedAssetStore {
|
|
6
|
+
bucketName;
|
|
7
|
+
objectPrefix;
|
|
8
|
+
requester;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.bucketName = options.bucketName;
|
|
11
|
+
this.objectPrefix = options.objectPrefix;
|
|
12
|
+
this.requester =
|
|
13
|
+
options.requester ??
|
|
14
|
+
new GoogleAuth({ scopes: ['https://www.googleapis.com/auth/devstorage.read_only'] });
|
|
15
|
+
}
|
|
16
|
+
async get(assetPath) {
|
|
17
|
+
if (!assetPath || assetPath.split('/').some((part) => !part || part === '.' || part === '..')) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const objectName = `${this.objectPrefix}/${assetPath}`;
|
|
22
|
+
const bucket = encodeURIComponent(this.bucketName);
|
|
23
|
+
const object = encodeURIComponent(objectName);
|
|
24
|
+
const url = `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${object}?alt=media`;
|
|
25
|
+
const response = await this.requester.request({ url, responseType: 'arraybuffer' });
|
|
26
|
+
return new Uint8Array(response.data);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const storageError = error;
|
|
30
|
+
if (storageError.code === 404 || storageError.status === 404 || storageError.response?.status === 404) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function assetContentType(path) {
|
|
38
|
+
if (path.endsWith('.js'))
|
|
39
|
+
return 'text/javascript; charset=utf-8';
|
|
40
|
+
if (path.endsWith('.css'))
|
|
41
|
+
return 'text/css; charset=utf-8';
|
|
42
|
+
if (path.endsWith('.svg'))
|
|
43
|
+
return 'image/svg+xml';
|
|
44
|
+
if (path.endsWith('.png'))
|
|
45
|
+
return 'image/png';
|
|
46
|
+
if (path.endsWith('.webp'))
|
|
47
|
+
return 'image/webp';
|
|
48
|
+
if (path.endsWith('.woff2'))
|
|
49
|
+
return 'font/woff2';
|
|
50
|
+
return 'application/octet-stream';
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface AppShell {
|
|
2
|
+
html: string;
|
|
3
|
+
etag: string;
|
|
4
|
+
buildId: string;
|
|
5
|
+
}
|
|
6
|
+
export interface LoadAppShellOptions {
|
|
7
|
+
staticDir: string;
|
|
8
|
+
app: string;
|
|
9
|
+
dataset: string;
|
|
10
|
+
buildId: string;
|
|
11
|
+
logToken?: string;
|
|
12
|
+
meta?: ReadonlyArray<readonly [name: string, value: string]>;
|
|
13
|
+
}
|
|
14
|
+
export declare function loadAppShell(opts: LoadAppShellOptions): Promise<AppShell>;
|
|
15
|
+
/** If-None-Match uses weak comparison for GET/HEAD, even for a strong ETag. */
|
|
16
|
+
export declare function etagMatches(header: string | undefined, etag: string): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Production app-shell rendering. The fully rendered bytes are held for the
|
|
2
|
+
// process lifetime by consumers: build identity and logging configuration are
|
|
3
|
+
// revision constants, and a stable body gives browsers a useful strong ETag.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
function escapeAttribute(value) {
|
|
7
|
+
return value
|
|
8
|
+
.replaceAll('&', '&')
|
|
9
|
+
.replaceAll('"', '"')
|
|
10
|
+
.replaceAll('<', '<')
|
|
11
|
+
.replaceAll('>', '>');
|
|
12
|
+
}
|
|
13
|
+
export async function loadAppShell(opts) {
|
|
14
|
+
const metas = [
|
|
15
|
+
['pw-log-app', opts.app],
|
|
16
|
+
['pw-log-dataset', opts.dataset],
|
|
17
|
+
['pw-build-id', opts.buildId],
|
|
18
|
+
...(opts.logToken ? [['pw-log-token', opts.logToken]] : []),
|
|
19
|
+
...(opts.meta ?? []),
|
|
20
|
+
];
|
|
21
|
+
const renderedMetas = metas
|
|
22
|
+
.map(([name, value]) => `<meta name="${escapeAttribute(name)}" content="${escapeAttribute(value)}">`)
|
|
23
|
+
.join('');
|
|
24
|
+
const source = await readFile(`${opts.staticDir}/index.html`, 'utf8');
|
|
25
|
+
const html = source.replace('</head>', `${renderedMetas}</head>`);
|
|
26
|
+
const digest = createHash('sha256').update(html).digest('base64url');
|
|
27
|
+
return { html, etag: `"${digest}"`, buildId: opts.buildId };
|
|
28
|
+
}
|
|
29
|
+
/** If-None-Match uses weak comparison for GET/HEAD, even for a strong ETag. */
|
|
30
|
+
export function etagMatches(header, etag) {
|
|
31
|
+
if (!header)
|
|
32
|
+
return false;
|
|
33
|
+
const normalize = (value) => value.trim().replace(/^W\//, '');
|
|
34
|
+
return header.split(',').some((candidate) => candidate.trim() === '*' || normalize(candidate) === etag);
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Logger } from '../log-browser.js';
|
|
2
|
+
import type { ReloadSafety } from './reload-safety.js';
|
|
3
|
+
export type BuildUpdateState = {
|
|
4
|
+
kind: 'current';
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'available';
|
|
7
|
+
buildId: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'reloading';
|
|
10
|
+
buildId: string;
|
|
11
|
+
};
|
|
12
|
+
export interface BuildUpdateDeps {
|
|
13
|
+
currentBuildId: string;
|
|
14
|
+
safety: ReloadSafety;
|
|
15
|
+
logger: Pick<Logger, 'info' | 'warn'>;
|
|
16
|
+
fetchFn?: (input: string, init?: RequestInit) => Promise<Response>;
|
|
17
|
+
reload?: () => void;
|
|
18
|
+
pollMs?: number;
|
|
19
|
+
onTrafficBuildChange?: (buildId: string) => void;
|
|
20
|
+
}
|
|
21
|
+
export declare function pageBuildId(): string;
|
|
22
|
+
export declare class BuildUpdateMonitor {
|
|
23
|
+
private deps;
|
|
24
|
+
private state;
|
|
25
|
+
private listeners;
|
|
26
|
+
private interval;
|
|
27
|
+
private stopSafety;
|
|
28
|
+
private started;
|
|
29
|
+
private lifecycle;
|
|
30
|
+
private requestNumber;
|
|
31
|
+
private appliedRequestNumber;
|
|
32
|
+
private trafficBuild;
|
|
33
|
+
private checkFailure;
|
|
34
|
+
constructor(deps: BuildUpdateDeps);
|
|
35
|
+
getState: () => BuildUpdateState;
|
|
36
|
+
subscribe: (listener: () => void) => (() => void);
|
|
37
|
+
start(): void;
|
|
38
|
+
stop(): void;
|
|
39
|
+
/** Additive sync-protocol observation. It can mark stale but cannot cancel
|
|
40
|
+
* an update because an old socket does not define current traffic. */
|
|
41
|
+
observeSocketBuild(buildId: string): void;
|
|
42
|
+
/** Deployment push is intentionally only a prompt to ask canonical HTTP;
|
|
43
|
+
* it carries no traffic authority of its own and runs while hidden too. */
|
|
44
|
+
observeDeploymentHint(): void;
|
|
45
|
+
checkNow(): Promise<void>;
|
|
46
|
+
private observeTrafficBuild;
|
|
47
|
+
private markAvailable;
|
|
48
|
+
private maybeReload;
|
|
49
|
+
private setState;
|
|
50
|
+
private onVisibility;
|
|
51
|
+
private onOnline;
|
|
52
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Long-lived-tab build pickup. HTTP says which revision owns new traffic;
|
|
2
|
+
// sync observations are an opportunistic second signal. Reload policy is
|
|
3
|
+
// separate and injected through ReloadSafety.
|
|
4
|
+
const DEFAULT_POLL_MS = 5 * 60 * 1000;
|
|
5
|
+
const LEGACY_BUILD = 'legacy-pre-build-id';
|
|
6
|
+
export function pageBuildId() {
|
|
7
|
+
return document.querySelector('meta[name="pw-build-id"]')?.content || 'development';
|
|
8
|
+
}
|
|
9
|
+
export class BuildUpdateMonitor {
|
|
10
|
+
deps;
|
|
11
|
+
state = { kind: 'current' };
|
|
12
|
+
listeners = new Set();
|
|
13
|
+
interval = null;
|
|
14
|
+
stopSafety = null;
|
|
15
|
+
started = false;
|
|
16
|
+
lifecycle = 0;
|
|
17
|
+
requestNumber = 0;
|
|
18
|
+
appliedRequestNumber = 0;
|
|
19
|
+
trafficBuild = null;
|
|
20
|
+
checkFailure = false;
|
|
21
|
+
constructor(deps) {
|
|
22
|
+
this.deps = deps;
|
|
23
|
+
}
|
|
24
|
+
getState = () => this.state;
|
|
25
|
+
subscribe = (listener) => {
|
|
26
|
+
this.listeners.add(listener);
|
|
27
|
+
return () => this.listeners.delete(listener);
|
|
28
|
+
};
|
|
29
|
+
start() {
|
|
30
|
+
if (this.started)
|
|
31
|
+
return;
|
|
32
|
+
this.started = true;
|
|
33
|
+
this.lifecycle += 1;
|
|
34
|
+
this.stopSafety = this.deps.safety.subscribe(() => this.maybeReload());
|
|
35
|
+
this.interval = setInterval(() => {
|
|
36
|
+
if (document.visibilityState === 'visible')
|
|
37
|
+
void this.checkNow();
|
|
38
|
+
}, this.deps.pollMs ?? DEFAULT_POLL_MS);
|
|
39
|
+
document.addEventListener('visibilitychange', this.onVisibility);
|
|
40
|
+
window.addEventListener('online', this.onOnline);
|
|
41
|
+
void this.checkNow();
|
|
42
|
+
}
|
|
43
|
+
stop() {
|
|
44
|
+
if (!this.started)
|
|
45
|
+
return;
|
|
46
|
+
this.started = false;
|
|
47
|
+
this.lifecycle += 1;
|
|
48
|
+
if (this.interval)
|
|
49
|
+
clearInterval(this.interval);
|
|
50
|
+
this.interval = null;
|
|
51
|
+
this.stopSafety?.();
|
|
52
|
+
this.stopSafety = null;
|
|
53
|
+
document.removeEventListener('visibilitychange', this.onVisibility);
|
|
54
|
+
window.removeEventListener('online', this.onOnline);
|
|
55
|
+
}
|
|
56
|
+
/** Additive sync-protocol observation. It can mark stale but cannot cancel
|
|
57
|
+
* an update because an old socket does not define current traffic. */
|
|
58
|
+
observeSocketBuild(buildId) {
|
|
59
|
+
if (buildId && buildId !== this.deps.currentBuildId)
|
|
60
|
+
this.markAvailable(buildId);
|
|
61
|
+
}
|
|
62
|
+
/** Deployment push is intentionally only a prompt to ask canonical HTTP;
|
|
63
|
+
* it carries no traffic authority of its own and runs while hidden too. */
|
|
64
|
+
observeDeploymentHint() {
|
|
65
|
+
void this.checkNow();
|
|
66
|
+
}
|
|
67
|
+
async checkNow() {
|
|
68
|
+
if (!navigator.onLine)
|
|
69
|
+
return;
|
|
70
|
+
const lifecycle = this.lifecycle;
|
|
71
|
+
const requestNumber = ++this.requestNumber;
|
|
72
|
+
try {
|
|
73
|
+
const response = await (this.deps.fetchFn ?? fetch)('/api/build', {
|
|
74
|
+
cache: 'no-store',
|
|
75
|
+
headers: { accept: 'application/json' },
|
|
76
|
+
});
|
|
77
|
+
if (!response.ok)
|
|
78
|
+
throw new Error(`build check failed: ${response.status}`);
|
|
79
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
80
|
+
let buildId;
|
|
81
|
+
if (contentType.includes('application/json')) {
|
|
82
|
+
const body = (await response.json());
|
|
83
|
+
if (typeof body.buildId !== 'string' || !body.buildId) {
|
|
84
|
+
throw new Error('build check returned no build ID');
|
|
85
|
+
}
|
|
86
|
+
buildId = body.buildId;
|
|
87
|
+
}
|
|
88
|
+
else if (contentType.includes('text/html')) {
|
|
89
|
+
buildId = LEGACY_BUILD;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
throw new Error(`unexpected build response: ${contentType || 'no content type'}`);
|
|
93
|
+
}
|
|
94
|
+
if (lifecycle !== this.lifecycle || requestNumber < this.appliedRequestNumber)
|
|
95
|
+
return;
|
|
96
|
+
this.appliedRequestNumber = requestNumber;
|
|
97
|
+
if (this.checkFailure) {
|
|
98
|
+
this.checkFailure = false;
|
|
99
|
+
this.deps.logger.info('build check recovered');
|
|
100
|
+
}
|
|
101
|
+
this.observeTrafficBuild(buildId);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (lifecycle !== this.lifecycle ||
|
|
105
|
+
requestNumber < this.appliedRequestNumber ||
|
|
106
|
+
!navigator.onLine)
|
|
107
|
+
return;
|
|
108
|
+
if (!this.checkFailure) {
|
|
109
|
+
this.checkFailure = true;
|
|
110
|
+
this.deps.logger.warn('build check failed', { error });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
observeTrafficBuild(buildId) {
|
|
115
|
+
const prior = this.trafficBuild;
|
|
116
|
+
this.trafficBuild = buildId;
|
|
117
|
+
if (buildId !== prior && (prior !== null || buildId !== this.deps.currentBuildId)) {
|
|
118
|
+
this.deps.onTrafficBuildChange?.(buildId);
|
|
119
|
+
}
|
|
120
|
+
if (buildId === this.deps.currentBuildId) {
|
|
121
|
+
if (this.state.kind === 'available')
|
|
122
|
+
this.setState({ kind: 'current' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.markAvailable(buildId);
|
|
126
|
+
}
|
|
127
|
+
markAvailable(buildId) {
|
|
128
|
+
if (this.state.kind === 'reloading')
|
|
129
|
+
return;
|
|
130
|
+
if (this.state.kind !== 'available' || this.state.buildId !== buildId) {
|
|
131
|
+
this.deps.logger.info('new build observed', {
|
|
132
|
+
pageBuildId: this.deps.currentBuildId,
|
|
133
|
+
observedBuildId: buildId,
|
|
134
|
+
});
|
|
135
|
+
this.setState({ kind: 'available', buildId });
|
|
136
|
+
}
|
|
137
|
+
this.maybeReload();
|
|
138
|
+
}
|
|
139
|
+
maybeReload() {
|
|
140
|
+
if (this.state.kind !== 'available' || !this.deps.safety.isSafe())
|
|
141
|
+
return;
|
|
142
|
+
const buildId = this.state.buildId;
|
|
143
|
+
this.setState({ kind: 'reloading', buildId });
|
|
144
|
+
if (this.deps.reload)
|
|
145
|
+
this.deps.reload();
|
|
146
|
+
else
|
|
147
|
+
location.reload();
|
|
148
|
+
}
|
|
149
|
+
setState(state) {
|
|
150
|
+
this.state = state;
|
|
151
|
+
for (const listener of this.listeners)
|
|
152
|
+
listener();
|
|
153
|
+
}
|
|
154
|
+
onVisibility = () => {
|
|
155
|
+
if (document.visibilityState === 'visible')
|
|
156
|
+
void this.checkNow();
|
|
157
|
+
};
|
|
158
|
+
onOnline = () => {
|
|
159
|
+
void this.checkNow();
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// One authority for controlled page reloads. Producers own named blockers;
|
|
2
|
+
// the update monitor only asks whether the aggregate is safe.
|
|
3
|
+
export class ReloadSafety {
|
|
4
|
+
blockers = new Set();
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
isSafe = () => this.blockers.size === 0;
|
|
7
|
+
has = (blocker) => this.blockers.has(blocker);
|
|
8
|
+
set(blocker, blocked) {
|
|
9
|
+
const changed = blocked ? !this.blockers.has(blocker) : this.blockers.has(blocker);
|
|
10
|
+
if (!changed)
|
|
11
|
+
return;
|
|
12
|
+
if (blocked)
|
|
13
|
+
this.blockers.add(blocker);
|
|
14
|
+
else
|
|
15
|
+
this.blockers.delete(blocker);
|
|
16
|
+
for (const listener of this.listeners)
|
|
17
|
+
listener();
|
|
18
|
+
}
|
|
19
|
+
subscribe = (listener) => {
|
|
20
|
+
this.listeners.add(listener);
|
|
21
|
+
return () => this.listeners.delete(listener);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare class ScopeBroadcaster<T> {
|
|
2
|
+
private readonly listeners;
|
|
3
|
+
subscribe(scopeKey: string, send: (message: T) => void): () => void;
|
|
4
|
+
/** Deliver to every matching listener in subscription order. Throwing
|
|
5
|
+
* listeners are detached immediately, but their errors are reported only
|
|
6
|
+
* after the remaining listeners have received the message. */
|
|
7
|
+
publish(scopeKey: string, message: T, except?: (message: T) => void): void;
|
|
8
|
+
count(scopeKey: string): number;
|
|
9
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export class ScopeBroadcaster {
|
|
2
|
+
listeners = new Map();
|
|
3
|
+
subscribe(scopeKey, send) {
|
|
4
|
+
let scopeListeners = this.listeners.get(scopeKey);
|
|
5
|
+
if (!scopeListeners) {
|
|
6
|
+
scopeListeners = new Set();
|
|
7
|
+
this.listeners.set(scopeKey, scopeListeners);
|
|
8
|
+
}
|
|
9
|
+
scopeListeners.add(send);
|
|
10
|
+
return () => {
|
|
11
|
+
scopeListeners.delete(send);
|
|
12
|
+
if (scopeListeners.size === 0 && this.listeners.get(scopeKey) === scopeListeners) {
|
|
13
|
+
this.listeners.delete(scopeKey);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Deliver to every matching listener in subscription order. Throwing
|
|
18
|
+
* listeners are detached immediately, but their errors are reported only
|
|
19
|
+
* after the remaining listeners have received the message. */
|
|
20
|
+
publish(scopeKey, message, except) {
|
|
21
|
+
const scopeListeners = this.listeners.get(scopeKey);
|
|
22
|
+
if (!scopeListeners)
|
|
23
|
+
return;
|
|
24
|
+
const errors = [];
|
|
25
|
+
for (const listener of scopeListeners) {
|
|
26
|
+
if (listener === except)
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
listener(message);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
scopeListeners.delete(listener);
|
|
33
|
+
errors.push(error);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (scopeListeners.size === 0 && this.listeners.get(scopeKey) === scopeListeners) {
|
|
37
|
+
this.listeners.delete(scopeKey);
|
|
38
|
+
}
|
|
39
|
+
if (errors.length === 1)
|
|
40
|
+
throw errors[0];
|
|
41
|
+
if (errors.length > 1)
|
|
42
|
+
throw new AggregateError(errors, 'scope broadcast failed');
|
|
43
|
+
}
|
|
44
|
+
count(scopeKey) {
|
|
45
|
+
return this.listeners.get(scopeKey)?.size ?? 0;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type pg from 'pg';
|
|
2
|
+
import type { Logger } from '../log.js';
|
|
3
|
+
import type { EventLogSchema } from './schema.js';
|
|
4
|
+
import { type ClientEvent, type EventBody, type PreparedEvent, type SyncEvent } from './types.js';
|
|
5
|
+
export type MutationPreparer<C extends EventBody, S extends EventBody> = (client: pg.PoolClient, scopeKey: string, event: ClientEvent<C>) => Promise<PreparedEvent<S>[]>;
|
|
6
|
+
export interface EventLogEngineOptions<C extends EventBody, S extends EventBody> {
|
|
7
|
+
pool: pg.Pool;
|
|
8
|
+
logger: Logger;
|
|
9
|
+
prepareMutation: MutationPreparer<C, S>;
|
|
10
|
+
schema?: EventLogSchema;
|
|
11
|
+
catchupLimit?: number;
|
|
12
|
+
retentionDays?: number;
|
|
13
|
+
randomGuid?: () => string;
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}
|
|
16
|
+
export type ApplyOutcome<S extends EventBody> = {
|
|
17
|
+
kind: 'applied';
|
|
18
|
+
events: SyncEvent<S>[];
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'duplicate';
|
|
21
|
+
event: SyncEvent<S>;
|
|
22
|
+
} | {
|
|
23
|
+
kind: 'rejected';
|
|
24
|
+
error: {
|
|
25
|
+
code: string;
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
export type CatchupOutcome<S extends EventBody> = {
|
|
30
|
+
kind: 'events';
|
|
31
|
+
events: SyncEvent<S>[];
|
|
32
|
+
currentSeq: number;
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'reload';
|
|
35
|
+
};
|
|
36
|
+
export declare class EventLogEngine<C extends EventBody, S extends EventBody> {
|
|
37
|
+
private readonly pool;
|
|
38
|
+
private readonly logger;
|
|
39
|
+
private readonly prepareMutation;
|
|
40
|
+
private readonly schema;
|
|
41
|
+
private readonly catchupLimit;
|
|
42
|
+
private readonly retentionDays;
|
|
43
|
+
private readonly randomGuid;
|
|
44
|
+
private readonly now;
|
|
45
|
+
constructor(options: EventLogEngineOptions<C, S>);
|
|
46
|
+
/** Apply one event. Domain validation failures are stable rejections; an
|
|
47
|
+
* unexpected failure is retried once because event GUID dedupe makes an
|
|
48
|
+
* ambiguous commit safe to replay. */
|
|
49
|
+
apply(scopeKey: string, clientGuid: string | null, event: ClientEvent<C>): Promise<ApplyOutcome<S>>;
|
|
50
|
+
private rejectedOutcome;
|
|
51
|
+
private applyOnce;
|
|
52
|
+
currentSeq(scopeKey: string): Promise<number>;
|
|
53
|
+
catchup(scopeKey: string, afterSeq: number): Promise<CatchupOutcome<S>>;
|
|
54
|
+
pruneOldEvents(): Promise<number>;
|
|
55
|
+
}
|