dignity.js 0.7.1 → 0.8.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,285 @@
1
+ const nacl = require('tweetnacl');
2
+ const naclUtil = require('tweetnacl-util');
3
+ const { stableStringify } = require('../security/message-security-service');
4
+
5
+ const DOMAIN_EVENT_SCHEMA_VERSION = 1;
6
+
7
+ const OPERATION_KIND_TO_EVENT_KIND = {
8
+ create: 'record:created',
9
+ update: 'record:updated',
10
+ delete: 'record:removed',
11
+ 'transfer-ownership': 'ownership:transferred'
12
+ };
13
+
14
+ function computeContentHash(data) {
15
+ const canonical = stableStringify(data || {});
16
+ const bytes = naclUtil.decodeUTF8(canonical);
17
+ const hash = nacl.hash(bytes);
18
+ const hex = Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join('');
19
+ return `sha512:${hex}`;
20
+ }
21
+
22
+ function canonicalEventBody(event) {
23
+ return stableStringify({
24
+ schemaVersion: event.schemaVersion,
25
+ eventId: event.eventId,
26
+ groupId: event.groupId,
27
+ publisherId: event.publisherId,
28
+ kind: event.kind,
29
+ collectionName: event.collectionName,
30
+ id: event.id,
31
+ payload: event.payload,
32
+ timestamp: event.timestamp,
33
+ baseVersion: event.baseVersion,
34
+ prevHash: event.prevHash || null,
35
+ newOwnerId: event.newOwnerId || null
36
+ });
37
+ }
38
+
39
+ function computeEventHash(event) {
40
+ const canonical = canonicalEventBody(event);
41
+ const bytes = naclUtil.decodeUTF8(canonical);
42
+ const hash = nacl.hash(bytes);
43
+ const hex = Array.from(hash, (b) => b.toString(16).padStart(2, '0')).join('');
44
+ return `sha512:${hex}`;
45
+ }
46
+
47
+ function operationToDomainEvent(operation, { publisherId, groupId, prevHash, eventIdGenerator }) {
48
+ if (!operation || !publisherId || !groupId) {
49
+ throw new Error('operationToDomainEvent requires operation, publisherId, and groupId');
50
+ }
51
+
52
+ const kind = OPERATION_KIND_TO_EVENT_KIND[operation.kind];
53
+ if (!kind) {
54
+ throw new Error(`Unsupported operation kind for domain event: ${operation.kind}`);
55
+ }
56
+
57
+ const event = {
58
+ schemaVersion: DOMAIN_EVENT_SCHEMA_VERSION,
59
+ eventId: eventIdGenerator ? eventIdGenerator() : `${Date.now()}-${Math.random().toString(16).slice(2)}`,
60
+ groupId,
61
+ publisherId,
62
+ kind,
63
+ collectionName: operation.collectionName,
64
+ id: operation.id,
65
+ payload: operation.payload || {},
66
+ timestamp: operation.timestamp,
67
+ baseVersion: operation.baseVersion || null,
68
+ prevHash: prevHash || null,
69
+ newOwnerId: operation.newOwnerId || null,
70
+ eventHash: null,
71
+ signature: null
72
+ };
73
+
74
+ event.eventHash = computeEventHash(event);
75
+ return event;
76
+ }
77
+
78
+ function signDomainEvent(event, signingSecretKey) {
79
+ if (!signingSecretKey) {
80
+ return { ...event };
81
+ }
82
+
83
+ const unsigned = { ...event, signature: null };
84
+ const eventHash = computeEventHash(unsigned);
85
+ const signature = nacl.sign.detached(
86
+ naclUtil.decodeUTF8(eventHash),
87
+ signingSecretKey
88
+ );
89
+
90
+ return {
91
+ ...unsigned,
92
+ eventHash,
93
+ signature: naclUtil.encodeBase64(signature)
94
+ };
95
+ }
96
+
97
+ function verifyDomainEventSignature(event, signingPublicKey) {
98
+ if (!event || !event.eventHash) {
99
+ return { ok: false, reason: 'missing-event-hash' };
100
+ }
101
+
102
+ const recomputed = computeEventHash({ ...event, signature: null });
103
+ if (recomputed !== event.eventHash) {
104
+ return { ok: false, reason: 'event-hash-mismatch' };
105
+ }
106
+
107
+ if (!event.signature) {
108
+ return { ok: true, unsigned: true };
109
+ }
110
+
111
+ if (!signingPublicKey) {
112
+ return { ok: false, reason: 'missing-public-key' };
113
+ }
114
+
115
+ const keyBytes = typeof signingPublicKey === 'string'
116
+ ? naclUtil.decodeBase64(signingPublicKey)
117
+ : signingPublicKey;
118
+
119
+ const valid = nacl.sign.detached.verify(
120
+ naclUtil.decodeUTF8(event.eventHash),
121
+ naclUtil.decodeBase64(event.signature),
122
+ keyBytes
123
+ );
124
+
125
+ return valid ? { ok: true } : { ok: false, reason: 'invalid-signature' };
126
+ }
127
+
128
+ function verifyDomainEvent(event, { signingPublicKey, supportedVersions } = {}) {
129
+ if (!event || typeof event !== 'object') {
130
+ return { ok: false, reason: 'invalid-event' };
131
+ }
132
+
133
+ const versions = supportedVersions || [DOMAIN_EVENT_SCHEMA_VERSION];
134
+ if (!versions.includes(event.schemaVersion)) {
135
+ return { ok: false, reason: 'unsupported-schema-version', schemaVersion: event.schemaVersion };
136
+ }
137
+
138
+ if (!event.eventId || !event.groupId || !event.publisherId || !event.kind) {
139
+ return { ok: false, reason: 'missing-required-fields' };
140
+ }
141
+
142
+ return verifyDomainEventSignature(event, signingPublicKey);
143
+ }
144
+
145
+ function createEmptyView(collections = []) {
146
+ const view = new Map();
147
+ for (const name of collections) {
148
+ view.set(name, new Map());
149
+ }
150
+ return view;
151
+ }
152
+
153
+ function ensureCollectionView(view, collectionName) {
154
+ if (!view.has(collectionName)) {
155
+ view.set(collectionName, new Map());
156
+ }
157
+ return view.get(collectionName);
158
+ }
159
+
160
+ function applyDomainEventToView(view, event, { collectionsFilter } = {}) {
161
+ if (!event || !event.collectionName) {
162
+ return { applied: false, reason: 'invalid-event' };
163
+ }
164
+
165
+ if (Array.isArray(collectionsFilter) && collectionsFilter.length > 0
166
+ && !collectionsFilter.includes(event.collectionName)) {
167
+ return { applied: false, reason: 'collection-filtered' };
168
+ }
169
+
170
+ const collection = ensureCollectionView(view, event.collectionName);
171
+
172
+ if (event.kind === 'record:created') {
173
+ if (collection.has(event.id)) {
174
+ return { applied: false, reason: 'already-exists' };
175
+ }
176
+ collection.set(event.id, {
177
+ id: event.id,
178
+ ownerId: event.publisherId,
179
+ data: { ...(event.payload || {}) },
180
+ hash: computeContentHash(event.payload || {}),
181
+ createdAt: event.timestamp,
182
+ updatedAt: event.timestamp,
183
+ deletedAt: null,
184
+ version: 1
185
+ });
186
+ return { applied: true, kind: event.kind };
187
+ }
188
+
189
+ const current = collection.get(event.id);
190
+ if (!current || current.deletedAt) {
191
+ if (event.kind === 'record:removed') {
192
+ return { applied: false, reason: 'not-found' };
193
+ }
194
+ return { applied: false, reason: 'missing-record' };
195
+ }
196
+
197
+ if (event.kind === 'record:updated') {
198
+ if (typeof event.baseVersion === 'number' && current.version !== event.baseVersion) {
199
+ return { applied: false, reason: 'version-conflict', currentVersion: current.version };
200
+ }
201
+ current.data = { ...current.data, ...(event.payload || {}) };
202
+ current.hash = computeContentHash(current.data);
203
+ current.updatedAt = event.timestamp;
204
+ current.version += 1;
205
+ return { applied: true, kind: event.kind };
206
+ }
207
+
208
+ if (event.kind === 'record:removed') {
209
+ if (typeof event.baseVersion === 'number' && current.version !== event.baseVersion) {
210
+ return { applied: false, reason: 'version-conflict', currentVersion: current.version };
211
+ }
212
+ current.deletedAt = event.timestamp;
213
+ current.version += 1;
214
+ return { applied: true, kind: event.kind };
215
+ }
216
+
217
+ if (event.kind === 'ownership:transferred') {
218
+ if (typeof event.baseVersion === 'number' && current.version !== event.baseVersion) {
219
+ return { applied: false, reason: 'version-conflict', currentVersion: current.version };
220
+ }
221
+ current.ownerId = event.newOwnerId;
222
+ current.updatedAt = event.timestamp;
223
+ current.version += 1;
224
+ return { applied: true, kind: event.kind };
225
+ }
226
+
227
+ return { applied: false, reason: 'unknown-kind' };
228
+ }
229
+
230
+ function verifyEventChain(events, { genesisHash = null } = {}) {
231
+ if (!Array.isArray(events) || events.length === 0) {
232
+ return { ok: true, length: 0 };
233
+ }
234
+
235
+ let expectedPrev = genesisHash;
236
+ for (let index = 0; index < events.length; index += 1) {
237
+ const event = events[index];
238
+ const prevHash = event.prevHash || null;
239
+
240
+ if (prevHash !== expectedPrev) {
241
+ return {
242
+ ok: false,
243
+ reason: 'chain-break',
244
+ index,
245
+ expectedPrev,
246
+ actualPrev: prevHash
247
+ };
248
+ }
249
+
250
+ const hashCheck = verifyDomainEventSignature(event, null);
251
+ if (!hashCheck.ok) {
252
+ return { ok: false, reason: hashCheck.reason, index };
253
+ }
254
+
255
+ expectedPrev = event.eventHash;
256
+ }
257
+
258
+ return { ok: true, length: events.length, lastHash: expectedPrev };
259
+ }
260
+
261
+ function buildCheckpoint(groupId, events, { publisherId } = {}) {
262
+ const chain = verifyEventChain(events);
263
+ return {
264
+ schemaVersion: DOMAIN_EVENT_SCHEMA_VERSION,
265
+ groupId,
266
+ publisherId: publisherId || null,
267
+ lastEventHash: chain.lastHash || null,
268
+ recordCount: events.length,
269
+ timestamp: Date.now()
270
+ };
271
+ }
272
+
273
+ module.exports = {
274
+ DOMAIN_EVENT_SCHEMA_VERSION,
275
+ OPERATION_KIND_TO_EVENT_KIND,
276
+ computeEventHash,
277
+ operationToDomainEvent,
278
+ signDomainEvent,
279
+ verifyDomainEvent,
280
+ verifyDomainEventSignature,
281
+ createEmptyView,
282
+ applyDomainEventToView,
283
+ verifyEventChain,
284
+ buildCheckpoint
285
+ };
@@ -0,0 +1,46 @@
1
+ const DEFAULT_LIVE_CAP = 5000;
2
+ const DEFAULT_BULK_INTERVAL_MS = 30000;
3
+
4
+ function assignPeerGroupTier({ joinIndex, liveCap = DEFAULT_LIVE_CAP, requestedTier, role }) {
5
+ if (role === 'publisher') {
6
+ return 'live';
7
+ }
8
+
9
+ if (requestedTier === 'live' || requestedTier === 'bulk') {
10
+ if (requestedTier === 'live' && joinIndex >= liveCap) {
11
+ return 'bulk';
12
+ }
13
+ return requestedTier;
14
+ }
15
+
16
+ return joinIndex < liveCap ? 'live' : 'bulk';
17
+ }
18
+
19
+ function getPeerTier(peer) {
20
+ return peer?.metadata?.peerGroupTier || peer?.peerGroupTier || null;
21
+ }
22
+
23
+ function filterPeersByTier(peers, tier) {
24
+ if (!tier) {
25
+ return peers;
26
+ }
27
+ return peers.filter((peer) => getPeerTier(peer) === tier);
28
+ }
29
+
30
+ function countLivePeers(peers) {
31
+ return peers.filter((peer) => getPeerTier(peer) === 'live').length;
32
+ }
33
+
34
+ function countBulkPeers(peers) {
35
+ return peers.filter((peer) => getPeerTier(peer) === 'bulk').length;
36
+ }
37
+
38
+ module.exports = {
39
+ DEFAULT_LIVE_CAP,
40
+ DEFAULT_BULK_INTERVAL_MS,
41
+ assignPeerGroupTier,
42
+ getPeerTier,
43
+ filterPeersByTier,
44
+ countLivePeers,
45
+ countBulkPeers
46
+ };
@@ -0,0 +1,213 @@
1
+ const EventEmitter = require('../utils/event-emitter');
2
+ const {
3
+ createEmptyView,
4
+ applyDomainEventToView,
5
+ verifyEventChain,
6
+ verifyDomainEvent,
7
+ DOMAIN_EVENT_SCHEMA_VERSION
8
+ } = require('./domain-events');
9
+
10
+ class DignityQueryReplica extends EventEmitter {
11
+ constructor(dignityP2P, { groupId, collections = [], tierMode = 'auto', publisherId = null } = {}) {
12
+ super();
13
+
14
+ if (!dignityP2P) {
15
+ throw new Error('DignityQueryReplica requires dignityP2P');
16
+ }
17
+ if (!groupId) {
18
+ throw new Error('DignityQueryReplica requires groupId');
19
+ }
20
+
21
+ this.dignity = dignityP2P;
22
+ this.groupId = groupId;
23
+ this.collections = [...collections];
24
+ this.tierMode = tierMode;
25
+ this.publisherId = publisherId;
26
+ this.view = createEmptyView(this.collections);
27
+ this.eventLog = [];
28
+ this.started = false;
29
+ this.boundDomainHandler = this.handleDomainEvent.bind(this);
30
+ this.boundPeerGroupHandler = this.handlePeerGroupMessage.bind(this);
31
+ }
32
+
33
+ async start(options = {}) {
34
+ if (this.started) {
35
+ return this;
36
+ }
37
+
38
+ await this.dignity.joinPeerGroup(this.groupId, {
39
+ tierMode: this.tierMode,
40
+ role: 'subscriber',
41
+ commandCapable: false,
42
+ domainEvents: true,
43
+ publisherId: this.publisherId,
44
+ liveCap: options.liveCap,
45
+ bulkIntervalMs: options.bulkIntervalMs,
46
+ bootstrapPeerIds: options.bootstrapPeerIds,
47
+ metadata: { role: 'subscriber', replica: true }
48
+ });
49
+
50
+ this.dignity.on('domainevent', this.boundDomainHandler);
51
+ this.dignity.on('peergroupmessage', this.boundPeerGroupHandler);
52
+ this.started = true;
53
+ this.emit('started', { groupId: this.groupId });
54
+ return this;
55
+ }
56
+
57
+ async stop() {
58
+ if (!this.started) {
59
+ return;
60
+ }
61
+
62
+ this.dignity.off('domainevent', this.boundDomainHandler);
63
+ this.dignity.off('peergroupmessage', this.boundPeerGroupHandler);
64
+ await this.dignity.leavePeerGroup(this.groupId);
65
+ this.started = false;
66
+ this.emit('stopped', { groupId: this.groupId });
67
+ }
68
+
69
+ handleDomainEvent(event) {
70
+ if (!event || event.groupId !== this.groupId) {
71
+ return;
72
+ }
73
+
74
+ if (this.publisherId && event.publisherId !== this.publisherId) {
75
+ return;
76
+ }
77
+
78
+ this.ingestEvent(event);
79
+ }
80
+
81
+ handlePeerGroupMessage(message) {
82
+ if (!message || message.groupId !== this.groupId) {
83
+ return;
84
+ }
85
+
86
+ if (message.type === 'domain:checkpoint') {
87
+ this.emit('checkpoint', message.payload);
88
+ }
89
+ }
90
+
91
+ ingestEvent(event, { skipChainCheck = false } = {}) {
92
+ const verified = verifyDomainEvent(event, {
93
+ supportedVersions: [DOMAIN_EVENT_SCHEMA_VERSION]
94
+ });
95
+
96
+ if (!verified.ok) {
97
+ this.emit('warning', { type: 'domain-event-rejected', reason: verified.reason, event });
98
+ return false;
99
+ }
100
+
101
+ if (!skipChainCheck && this.eventLog.length > 0) {
102
+ const lastHash = this.eventLog[this.eventLog.length - 1].eventHash;
103
+ if (event.prevHash !== lastHash) {
104
+ this.emit('chainbroken', {
105
+ groupId: this.groupId,
106
+ expectedPrev: lastHash,
107
+ actualPrev: event.prevHash,
108
+ eventId: event.eventId
109
+ });
110
+ return false;
111
+ }
112
+ } else if (!skipChainCheck && this.eventLog.length === 0 && event.prevHash) {
113
+ this.emit('chainbroken', {
114
+ groupId: this.groupId,
115
+ expectedPrev: null,
116
+ actualPrev: event.prevHash,
117
+ eventId: event.eventId
118
+ });
119
+ return false;
120
+ }
121
+
122
+ const duplicate = this.eventLog.some((entry) => entry.eventId === event.eventId);
123
+ if (duplicate) {
124
+ return false;
125
+ }
126
+
127
+ const result = applyDomainEventToView(this.view, event, {
128
+ collectionsFilter: this.collections.length > 0 ? this.collections : null
129
+ });
130
+
131
+ if (result.applied || result.reason === 'collection-filtered') {
132
+ this.eventLog.push({ ...event });
133
+ this.emit('change', { event, result });
134
+ return true;
135
+ }
136
+
137
+ this.emit('warning', { type: 'domain-event-not-applied', reason: result.reason, event });
138
+ return false;
139
+ }
140
+
141
+ read(collectionName, id) {
142
+ const collection = this.view.get(collectionName);
143
+ if (!collection) {
144
+ return null;
145
+ }
146
+ const record = collection.get(id);
147
+ if (!record || record.deletedAt) {
148
+ return null;
149
+ }
150
+ return { ...record, data: { ...record.data } };
151
+ }
152
+
153
+ list(collectionName, options = {}) {
154
+ const collection = this.view.get(collectionName);
155
+ if (!collection) {
156
+ return [];
157
+ }
158
+
159
+ const includeDeleted = options.includeDeleted || false;
160
+ const records = [];
161
+
162
+ for (const record of collection.values()) {
163
+ if (record.deletedAt && !includeDeleted) {
164
+ continue;
165
+ }
166
+ if (record.deletedAt && includeDeleted) {
167
+ records.push({
168
+ id: record.id,
169
+ ownerId: record.ownerId,
170
+ deletedAt: record.deletedAt,
171
+ version: record.version
172
+ });
173
+ continue;
174
+ }
175
+ records.push({ ...record, data: { ...record.data } });
176
+ }
177
+
178
+ return records;
179
+ }
180
+
181
+ verifyChain() {
182
+ const result = verifyEventChain(this.eventLog);
183
+ if (!result.ok) {
184
+ this.emit('chainbroken', { groupId: this.groupId, ...result });
185
+ }
186
+ return result;
187
+ }
188
+
189
+ getViewStats() {
190
+ const stats = {
191
+ groupId: this.groupId,
192
+ eventCount: this.eventLog.length,
193
+ collections: {}
194
+ };
195
+
196
+ for (const [name, collection] of this.view.entries()) {
197
+ let active = 0;
198
+ let deleted = 0;
199
+ for (const record of collection.values()) {
200
+ if (record.deletedAt) {
201
+ deleted += 1;
202
+ } else {
203
+ active += 1;
204
+ }
205
+ }
206
+ stats.collections[name] = { active, deleted };
207
+ }
208
+
209
+ return stats;
210
+ }
211
+ }
212
+
213
+ module.exports = DignityQueryReplica;
@@ -3,7 +3,7 @@ const PEER_GROUP_SCOPE_PREFIX = 'gossip:';
3
3
  const DEFAULT_PEER_GROUP_OPTIONS = {
4
4
  fanout: 3,
5
5
  maxActivePeers: 8,
6
- maxHops: 6,
6
+ maxHops: 64,
7
7
  relayEnabled: true
8
8
  };
9
9
 
package/src/index.js CHANGED
@@ -49,6 +49,24 @@ const {
49
49
  parsePeerGroupScope,
50
50
  selectFanoutPeers
51
51
  } = require('./gossip/peer-group');
52
+ const {
53
+ DOMAIN_EVENT_SCHEMA_VERSION,
54
+ operationToDomainEvent,
55
+ signDomainEvent,
56
+ verifyDomainEvent,
57
+ verifyEventChain,
58
+ buildCheckpoint,
59
+ createEmptyView,
60
+ applyDomainEventToView
61
+ } = require('./cqrs/domain-events');
62
+ const {
63
+ DEFAULT_LIVE_CAP,
64
+ DEFAULT_BULK_INTERVAL_MS,
65
+ assignPeerGroupTier,
66
+ filterPeersByTier
67
+ } = require('./cqrs/peer-group-tiers');
68
+ const { electBulkRelays, DEFAULT_BULK_RELAY_COUNT } = require('./cqrs/bulk-relay');
69
+ const DignityQueryReplica = require('./cqrs/query-replica');
52
70
 
53
71
  module.exports = {
54
72
  DignityP2P,
@@ -83,5 +101,20 @@ module.exports = {
83
101
  DEFAULT_PEER_GROUP_OPTIONS,
84
102
  peerGroupScope,
85
103
  parsePeerGroupScope,
86
- selectFanoutPeers
104
+ selectFanoutPeers,
105
+ DOMAIN_EVENT_SCHEMA_VERSION,
106
+ operationToDomainEvent,
107
+ signDomainEvent,
108
+ verifyDomainEvent,
109
+ verifyEventChain,
110
+ buildCheckpoint,
111
+ createEmptyView,
112
+ applyDomainEventToView,
113
+ DEFAULT_LIVE_CAP,
114
+ DEFAULT_BULK_INTERVAL_MS,
115
+ assignPeerGroupTier,
116
+ filterPeersByTier,
117
+ electBulkRelays,
118
+ DEFAULT_BULK_RELAY_COUNT,
119
+ DignityQueryReplica
87
120
  };