@steve31415/baselib 2.0.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.
@@ -0,0 +1,197 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { SyncError, } from './types.js';
3
+ const DEFAULT_CATCHUP_LIMIT = 100;
4
+ const DEFAULT_RETENTION_DAYS = 7;
5
+ const IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
6
+ const DEFAULT_SCHEMA = {
7
+ scopesTable: 'sync_scopes',
8
+ eventsTable: 'sync_events',
9
+ scopeColumn: 'scope_key',
10
+ };
11
+ function validatedSchema(schema) {
12
+ const scopesTable = schema.scopesTable;
13
+ const eventsTable = schema.eventsTable;
14
+ const scopeColumn = schema.scopeColumn;
15
+ const validate = (field, identifier) => {
16
+ if (typeof identifier !== 'string' || !IDENTIFIER.test(identifier)) {
17
+ throw new Error(`invalid SQL identifier for ${field}`);
18
+ }
19
+ return identifier;
20
+ };
21
+ return {
22
+ scopesTable: validate('scopesTable', scopesTable),
23
+ eventsTable: validate('eventsTable', eventsTable),
24
+ scopeColumn: validate('scopeColumn', scopeColumn),
25
+ };
26
+ }
27
+ function syncEventFromRow(row) {
28
+ return {
29
+ seq: Number(row.seq),
30
+ eventGuid: row.event_guid,
31
+ ts: row.ts.getTime(),
32
+ type: row.type,
33
+ payload: row.payload,
34
+ };
35
+ }
36
+ export class EventLogEngine {
37
+ pool;
38
+ logger;
39
+ prepareMutation;
40
+ schema;
41
+ catchupLimit;
42
+ retentionDays;
43
+ randomGuid;
44
+ now;
45
+ constructor(options) {
46
+ this.pool = options.pool;
47
+ this.logger = options.logger;
48
+ this.prepareMutation = options.prepareMutation;
49
+ this.schema = validatedSchema(options.schema ?? DEFAULT_SCHEMA);
50
+ this.catchupLimit = options.catchupLimit ?? DEFAULT_CATCHUP_LIMIT;
51
+ this.retentionDays = options.retentionDays ?? DEFAULT_RETENTION_DAYS;
52
+ this.randomGuid = options.randomGuid ?? randomUUID;
53
+ this.now = options.now ?? Date.now;
54
+ }
55
+ /** Apply one event. Domain validation failures are stable rejections; an
56
+ * unexpected failure is retried once because event GUID dedupe makes an
57
+ * ambiguous commit safe to replay. */
58
+ async apply(scopeKey, clientGuid, event) {
59
+ try {
60
+ return await this.applyOnce(scopeKey, clientGuid, event);
61
+ }
62
+ catch (error) {
63
+ if (error instanceof SyncError)
64
+ return this.rejectedOutcome(scopeKey, event, error);
65
+ this.logger.warn('sync.apply_retry', {
66
+ scopeKey,
67
+ eventGuid: event.eventGuid,
68
+ eventType: event.type,
69
+ error,
70
+ });
71
+ try {
72
+ return await this.applyOnce(scopeKey, clientGuid, event);
73
+ }
74
+ catch (retryError) {
75
+ if (retryError instanceof SyncError) {
76
+ return this.rejectedOutcome(scopeKey, event, retryError);
77
+ }
78
+ this.logger.error('sync.apply_failed', {
79
+ scopeKey,
80
+ eventGuid: event.eventGuid,
81
+ eventType: event.type,
82
+ error: retryError,
83
+ });
84
+ return {
85
+ kind: 'rejected',
86
+ error: { code: 'internal', message: 'internal error applying event' },
87
+ };
88
+ }
89
+ }
90
+ }
91
+ rejectedOutcome(scopeKey, event, error) {
92
+ this.logger.warn('sync.rejected', {
93
+ scopeKey,
94
+ eventGuid: event.eventGuid,
95
+ eventType: event.type,
96
+ error,
97
+ });
98
+ return { kind: 'rejected', error: { code: error.code, message: error.message } };
99
+ }
100
+ async applyOnce(scopeKey, clientGuid, event) {
101
+ const client = await this.pool.connect();
102
+ const { scopesTable, eventsTable, scopeColumn } = this.schema;
103
+ try {
104
+ await client.query('begin');
105
+ await client.query(`insert into ${scopesTable} (${scopeColumn}) values ($1) on conflict do nothing`, [scopeKey]);
106
+ await client.query(`select last_seq from ${scopesTable} where ${scopeColumn} = $1 for update`, [scopeKey]);
107
+ const duplicate = await client.query(`select ${scopeColumn} as scope_key, seq, event_guid, ts, type, payload
108
+ from ${eventsTable} where event_guid = $1`, [event.eventGuid]);
109
+ if (duplicate.rows.length > 0) {
110
+ await client.query('commit');
111
+ const row = duplicate.rows[0];
112
+ if (row.scope_key !== scopeKey) {
113
+ return {
114
+ kind: 'rejected',
115
+ error: { code: 'guid_conflict', message: 'event guid already used' },
116
+ };
117
+ }
118
+ return { kind: 'duplicate', event: syncEventFromRow(row) };
119
+ }
120
+ const prepared = await this.prepareMutation(client, scopeKey, event);
121
+ if (prepared.filter((candidate) => candidate.trigger === true).length !== 1) {
122
+ throw new Error('mutation preparer must return exactly one trigger event');
123
+ }
124
+ const eventCount = prepared.length;
125
+ const bump = await client.query(`update ${scopesTable} set last_seq = last_seq + $2
126
+ where ${scopeColumn} = $1 returning last_seq`, [scopeKey, eventCount]);
127
+ let seq = Number(bump.rows[0].last_seq) - eventCount;
128
+ const timestamp = this.now();
129
+ const events = prepared.map((candidate) => {
130
+ return {
131
+ seq: ++seq,
132
+ eventGuid: candidate.trigger ? event.eventGuid : this.randomGuid(),
133
+ ts: timestamp,
134
+ type: candidate.type,
135
+ payload: candidate.payload,
136
+ };
137
+ });
138
+ for (let index = 0; index < events.length; index++) {
139
+ const appended = events[index];
140
+ await client.query(`insert into ${eventsTable}
141
+ (${scopeColumn}, seq, event_guid, client_guid, ts, type, payload)
142
+ values ($1, $2, $3, $4, $5, $6, $7)`, [
143
+ scopeKey,
144
+ appended.seq,
145
+ appended.eventGuid,
146
+ prepared[index].trigger ? clientGuid : null,
147
+ new Date(appended.ts),
148
+ appended.type,
149
+ JSON.stringify(appended.payload),
150
+ ]);
151
+ }
152
+ await client.query('commit');
153
+ return { kind: 'applied', events };
154
+ }
155
+ catch (error) {
156
+ await client.query('rollback').catch(() => { });
157
+ throw error;
158
+ }
159
+ finally {
160
+ client.release();
161
+ }
162
+ }
163
+ async currentSeq(scopeKey) {
164
+ const { scopesTable, scopeColumn } = this.schema;
165
+ const result = await this.pool.query(`select last_seq from ${scopesTable} where ${scopeColumn} = $1`, [scopeKey]);
166
+ return result.rows.length === 0 ? 0 : Number(result.rows[0].last_seq);
167
+ }
168
+ async catchup(scopeKey, afterSeq) {
169
+ const currentSeq = await this.currentSeq(scopeKey);
170
+ if (afterSeq > currentSeq)
171
+ return { kind: 'reload' };
172
+ if (afterSeq === currentSeq)
173
+ return { kind: 'events', events: [], currentSeq };
174
+ if (currentSeq - afterSeq > this.catchupLimit)
175
+ return { kind: 'reload' };
176
+ const { eventsTable, scopeColumn } = this.schema;
177
+ const result = await this.pool.query(`select seq, event_guid, ts, type, payload from ${eventsTable}
178
+ where ${scopeColumn} = $1 and seq > $2 and seq <= $3 order by seq`, [scopeKey, afterSeq, currentSeq]);
179
+ if (result.rows.length === 0 || Number(result.rows[0].seq) !== afterSeq + 1) {
180
+ return { kind: 'reload' };
181
+ }
182
+ if (result.rows.length !== currentSeq - afterSeq ||
183
+ result.rows.some((row, index) => Number(row.seq) !== afterSeq + index + 1)) {
184
+ return { kind: 'reload' };
185
+ }
186
+ return {
187
+ kind: 'events',
188
+ events: result.rows.map((row) => syncEventFromRow(row)),
189
+ currentSeq,
190
+ };
191
+ }
192
+ async pruneOldEvents() {
193
+ const result = await this.pool.query(`delete from ${this.schema.eventsTable}
194
+ where ts < now() - make_interval(days => $1)`, [this.retentionDays]);
195
+ return result.rowCount ?? 0;
196
+ }
197
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './schema.js';
3
+ export * from './engine.js';
4
+ export * from './broadcaster.js';
5
+ export * from './session.js';
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './schema.js';
3
+ export * from './engine.js';
4
+ export * from './broadcaster.js';
5
+ export * from './session.js';
@@ -0,0 +1,13 @@
1
+ /** Names of the protocol tables and scope column used by an event log. */
2
+ export interface EventLogSchema {
3
+ scopesTable: string;
4
+ eventsTable: string;
5
+ scopeColumn: string;
6
+ }
7
+ /**
8
+ * Default PostgreSQL schema for the generic event log.
9
+ *
10
+ * Applications with an existing event-log schema can retain it by passing a
11
+ * validated EventLogSchema to the sync engine instead of applying this DDL.
12
+ */
13
+ export declare const SYNC_SCHEMA_SQL = "\ncreate table if not exists sync_scopes (\n scope_key text primary key,\n last_seq bigint not null default 0\n);\n\ncreate table if not exists sync_events (\n scope_key text not null,\n seq bigint not null,\n event_guid uuid not null unique,\n client_guid text,\n ts timestamptz not null default now(),\n type text not null,\n payload jsonb not null,\n primary key (scope_key, seq),\n foreign key (scope_key) references sync_scopes(scope_key)\n);\n";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Default PostgreSQL schema for the generic event log.
3
+ *
4
+ * Applications with an existing event-log schema can retain it by passing a
5
+ * validated EventLogSchema to the sync engine instead of applying this DDL.
6
+ */
7
+ export const SYNC_SCHEMA_SQL = `
8
+ create table if not exists sync_scopes (
9
+ scope_key text primary key,
10
+ last_seq bigint not null default 0
11
+ );
12
+
13
+ create table if not exists sync_events (
14
+ scope_key text not null,
15
+ seq bigint not null,
16
+ event_guid uuid not null unique,
17
+ client_guid text,
18
+ ts timestamptz not null default now(),
19
+ type text not null,
20
+ payload jsonb not null,
21
+ primary key (scope_key, seq),
22
+ foreign key (scope_key) references sync_scopes(scope_key)
23
+ );
24
+ `;
@@ -0,0 +1,29 @@
1
+ import { ScopeBroadcaster } from './broadcaster.js';
2
+ import { EventLogEngine } from './engine.js';
3
+ import { type ClientEvent, type EventBody, type HttpEventOutcome, type WsServerMessage } from './types.js';
4
+ export interface SyncSocketSessionOptions<C extends EventBody, S extends EventBody> {
5
+ scopeKey: string;
6
+ engine: EventLogEngine<C, S>;
7
+ broadcaster: ScopeBroadcaster<WsServerMessage<S>>;
8
+ send(message: WsServerMessage<S>): void;
9
+ buildId: string;
10
+ parseClientEvent(value: unknown): ClientEvent<C>;
11
+ }
12
+ export declare class SyncSocketSession<C extends EventBody, S extends EventBody> {
13
+ private readonly scopeKey;
14
+ private readonly engine;
15
+ private readonly broadcaster;
16
+ private readonly send;
17
+ private readonly buildId;
18
+ private readonly parseClientEvent;
19
+ private readonly broadcastListener;
20
+ private readonly unsubscribe;
21
+ private clientGuid;
22
+ private closed;
23
+ constructor(options: SyncSocketSessionOptions<C, S>);
24
+ onMessage(message: unknown): Promise<void>;
25
+ close(): void;
26
+ private reject;
27
+ private sendToSocket;
28
+ }
29
+ export declare function applyHttpEventBatch<C extends EventBody, S extends EventBody>(engine: EventLogEngine<C, S>, scopeKey: string, request: unknown, parseClientEvent: (value: unknown) => ClientEvent<C>): Promise<HttpEventOutcome[]>;
@@ -0,0 +1,189 @@
1
+ import { SyncError, } from './types.js';
2
+ function record(value) {
3
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
4
+ ? value
5
+ : null;
6
+ }
7
+ function eventGuid(value) {
8
+ const candidate = record(value);
9
+ return typeof candidate?.eventGuid === 'string' ? candidate.eventGuid : '';
10
+ }
11
+ function parserError(error) {
12
+ return error instanceof SyncError
13
+ ? { code: error.code, message: error.message }
14
+ : { code: 'invalid_payload', message: 'invalid event' };
15
+ }
16
+ export class SyncSocketSession {
17
+ scopeKey;
18
+ engine;
19
+ broadcaster;
20
+ send;
21
+ buildId;
22
+ parseClientEvent;
23
+ broadcastListener;
24
+ unsubscribe;
25
+ clientGuid = null;
26
+ closed = false;
27
+ constructor(options) {
28
+ this.scopeKey = options.scopeKey;
29
+ this.engine = options.engine;
30
+ this.broadcaster = options.broadcaster;
31
+ this.send = options.send;
32
+ this.buildId = options.buildId;
33
+ this.parseClientEvent = options.parseClientEvent;
34
+ this.broadcastListener = (message) => this.sendToSocket(message);
35
+ this.unsubscribe = this.broadcaster.subscribe(this.scopeKey, this.broadcastListener);
36
+ }
37
+ async onMessage(message) {
38
+ if (this.closed)
39
+ return;
40
+ const envelope = record(message);
41
+ if (!envelope || typeof envelope.type !== 'string') {
42
+ this.reject('', 'bad_message', 'invalid client message');
43
+ return;
44
+ }
45
+ if (envelope.type === 'ping') {
46
+ this.sendToSocket({ type: 'pong', buildId: this.buildId });
47
+ return;
48
+ }
49
+ if (envelope.type === 'connect') {
50
+ if (typeof envelope.clientGuid !== 'string' ||
51
+ envelope.clientGuid.length === 0 ||
52
+ !Number.isSafeInteger(envelope.lastSeq) ||
53
+ envelope.lastSeq < 0 ||
54
+ (this.clientGuid !== null && this.clientGuid !== envelope.clientGuid)) {
55
+ this.reject('', 'bad_message', 'invalid connect message');
56
+ return;
57
+ }
58
+ this.clientGuid = envelope.clientGuid;
59
+ const outcome = await this.engine.catchup(this.scopeKey, envelope.lastSeq);
60
+ if (this.closed)
61
+ return;
62
+ if (outcome.kind === 'reload') {
63
+ this.sendToSocket({ type: 'reload' });
64
+ }
65
+ else {
66
+ this.sendToSocket({
67
+ type: 'catchup',
68
+ events: outcome.events,
69
+ currentSeq: outcome.currentSeq,
70
+ buildId: this.buildId,
71
+ });
72
+ }
73
+ return;
74
+ }
75
+ if (envelope.type !== 'event' ||
76
+ typeof envelope.clientGuid !== 'string' ||
77
+ envelope.clientGuid.length === 0 ||
78
+ !('event' in envelope)) {
79
+ this.reject('', 'bad_message', 'invalid client message');
80
+ return;
81
+ }
82
+ const guid = eventGuid(envelope.event);
83
+ let parsedEvent;
84
+ try {
85
+ parsedEvent = this.parseClientEvent(envelope.event);
86
+ }
87
+ catch (error) {
88
+ const parsedError = parserError(error);
89
+ this.reject(guid, parsedError.code, parsedError.message);
90
+ return;
91
+ }
92
+ if (this.clientGuid === null) {
93
+ this.reject(guid, 'not_connected', 'connect before sending events');
94
+ return;
95
+ }
96
+ if (envelope.clientGuid !== this.clientGuid) {
97
+ this.reject(guid, 'client_guid_mismatch', 'event client guid does not match socket');
98
+ return;
99
+ }
100
+ const outcome = await this.engine.apply(this.scopeKey, this.clientGuid, parsedEvent);
101
+ if (outcome.kind === 'applied') {
102
+ // A committed event must reach every healthy peer even when this socket
103
+ // or another peer is stale. Report delivery failures only after fan-out.
104
+ const deliveryErrors = [];
105
+ for (const appliedEvent of outcome.events) {
106
+ const outgoing = { type: 'event', event: appliedEvent };
107
+ if (!this.closed) {
108
+ try {
109
+ this.sendToSocket(outgoing);
110
+ }
111
+ catch (error) {
112
+ deliveryErrors.push(error);
113
+ }
114
+ }
115
+ try {
116
+ this.broadcaster.publish(this.scopeKey, outgoing, this.broadcastListener);
117
+ }
118
+ catch (error) {
119
+ deliveryErrors.push(error);
120
+ }
121
+ }
122
+ if (deliveryErrors.length === 1)
123
+ throw deliveryErrors[0];
124
+ if (deliveryErrors.length > 1) {
125
+ throw new AggregateError(deliveryErrors, 'sync event delivery failed');
126
+ }
127
+ }
128
+ else if (outcome.kind === 'duplicate') {
129
+ if (!this.closed)
130
+ this.sendToSocket({ type: 'event', event: outcome.event });
131
+ }
132
+ else if (!this.closed) {
133
+ this.sendToSocket({
134
+ type: 'rejection',
135
+ eventGuid: parsedEvent.eventGuid,
136
+ error: outcome.error,
137
+ });
138
+ }
139
+ }
140
+ close() {
141
+ if (this.closed)
142
+ return;
143
+ this.closed = true;
144
+ this.unsubscribe();
145
+ }
146
+ reject(eventGuid, code, message) {
147
+ this.sendToSocket({ type: 'rejection', eventGuid, error: { code, message } });
148
+ }
149
+ sendToSocket(message) {
150
+ if (this.closed)
151
+ return;
152
+ try {
153
+ this.send(message);
154
+ }
155
+ catch (error) {
156
+ this.close();
157
+ throw error;
158
+ }
159
+ }
160
+ }
161
+ export async function applyHttpEventBatch(engine, scopeKey, request, parseClientEvent) {
162
+ const envelope = record(request);
163
+ if (!envelope ||
164
+ typeof envelope.clientGuid !== 'string' ||
165
+ envelope.clientGuid.length === 0 ||
166
+ !Array.isArray(envelope.events)) {
167
+ throw new SyncError('bad_request', 'invalid event batch');
168
+ }
169
+ const outcomes = [];
170
+ for (const value of envelope.events) {
171
+ const guid = eventGuid(value);
172
+ let event;
173
+ try {
174
+ event = parseClientEvent(value);
175
+ }
176
+ catch (error) {
177
+ outcomes.push({ eventGuid: guid, status: 'rejected', error: parserError(error) });
178
+ continue;
179
+ }
180
+ const outcome = await engine.apply(scopeKey, envelope.clientGuid, event);
181
+ if (outcome.kind === 'rejected') {
182
+ outcomes.push({ eventGuid: event.eventGuid, status: 'rejected', error: outcome.error });
183
+ }
184
+ else {
185
+ outcomes.push({ eventGuid: event.eventGuid, status: 'applied' });
186
+ }
187
+ }
188
+ return outcomes;
189
+ }
@@ -0,0 +1,79 @@
1
+ export interface EventBody<TType extends string = string, TPayload = unknown> {
2
+ type: TType;
3
+ payload: TPayload;
4
+ }
5
+ export type ClientEvent<TBody extends EventBody> = {
6
+ eventGuid: string;
7
+ } & TBody;
8
+ export type PreparedEvent<TBody extends EventBody> = TBody & {
9
+ trigger: boolean;
10
+ };
11
+ export type SyncEvent<TBody extends EventBody> = {
12
+ seq: number;
13
+ eventGuid: string;
14
+ ts: number;
15
+ } & TBody;
16
+ export declare class SyncError extends Error {
17
+ readonly code: string;
18
+ constructor(code: string, message: string);
19
+ }
20
+ export interface WsConnect {
21
+ type: 'connect';
22
+ clientGuid: string;
23
+ lastSeq: number;
24
+ }
25
+ export interface WsClientEvent<T extends EventBody> {
26
+ type: 'event';
27
+ clientGuid: string;
28
+ event: ClientEvent<T>;
29
+ }
30
+ export interface WsPing {
31
+ type: 'ping';
32
+ }
33
+ export type WsClientMessage<T extends EventBody> = WsConnect | WsClientEvent<T> | WsPing;
34
+ export interface WsEventBroadcast<T extends EventBody> {
35
+ type: 'event';
36
+ event: SyncEvent<T>;
37
+ }
38
+ export interface WsCatchup<T extends EventBody> {
39
+ type: 'catchup';
40
+ events: SyncEvent<T>[];
41
+ currentSeq: number;
42
+ buildId?: string;
43
+ }
44
+ export interface WsReload {
45
+ type: 'reload';
46
+ }
47
+ export interface WsRejection {
48
+ type: 'rejection';
49
+ eventGuid: string;
50
+ error: {
51
+ code: string;
52
+ message: string;
53
+ };
54
+ }
55
+ export interface WsPong {
56
+ type: 'pong';
57
+ buildId?: string;
58
+ }
59
+ /** Prompt to verify the canonical traffic build over authenticated HTTP. */
60
+ export interface WsBuildChanged {
61
+ type: 'build_changed';
62
+ }
63
+ /** Durable events may have advanced; answer with connect(lastSeq). */
64
+ export interface WsSyncAvailable {
65
+ type: 'sync_available';
66
+ }
67
+ export type WsServerMessage<T extends EventBody> = WsEventBroadcast<T> | WsCatchup<T> | WsReload | WsRejection | WsPong | WsBuildChanged | WsSyncAvailable;
68
+ export interface HttpEventsRequest<T extends EventBody> {
69
+ clientGuid: string;
70
+ events: ClientEvent<T>[];
71
+ }
72
+ export interface HttpEventOutcome {
73
+ eventGuid: string;
74
+ status: 'applied' | 'rejected';
75
+ error?: {
76
+ code: string;
77
+ message: string;
78
+ };
79
+ }
@@ -0,0 +1,8 @@
1
+ export class SyncError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = 'SyncError';
7
+ }
8
+ }