@emmett-community/emmett-google-firestore 0.1.0 → 0.3.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 CHANGED
@@ -234,38 +234,17 @@ const state = await eventStore.aggregateStream(
234
234
 
235
235
  ## Testing
236
236
 
237
- ### Testing Utilities
238
-
239
- The package includes utilities to make testing easier:
240
-
241
- ```typescript
242
- import {
243
- setupFirestoreTests,
244
- getTestFirestore,
245
- clearFirestore,
246
- } from '@emmett-community/emmett-google-firestore/testing';
247
-
248
- describe('My Tests', () => {
249
- const { firestore, eventStore, cleanup, clearData } = setupFirestoreTests();
250
-
251
- afterAll(cleanup);
252
- beforeEach(clearData);
253
-
254
- it('should work', async () => {
255
- await eventStore.appendToStream('test-stream', [/* events */]);
256
- // ... assertions
257
- });
258
- });
259
- ```
260
-
261
237
  ### Running Tests
262
238
 
263
239
  ```bash
264
240
  # Unit tests
265
241
  npm run test:unit
266
242
 
267
- # Integration tests (requires Firestore Emulator)
268
- npm run test:integration
243
+ # Integration tests (in-memory)
244
+ npm run test:int
245
+
246
+ # E2E tests (Firestore Emulator via Testcontainers, requires Docker)
247
+ npm run test:e2e
269
248
 
270
249
  # All tests
271
250
  npm test
@@ -274,9 +253,17 @@ npm test
274
253
  npm run test:coverage
275
254
  ```
276
255
 
256
+ Test files live in `test/` and are selected by filename suffix:
257
+
258
+ - `*.unit.spec.ts` (unit tests, pure logic)
259
+ - `*.int.spec.ts` (integration tests, in-memory Firestore)
260
+ - `*.e2e.spec.ts` (E2E tests, Firestore emulator via Testcontainers)
261
+
262
+ Support fixtures live under `test/support` (including Firebase emulator configs in `test/support/firebase`).
263
+
277
264
  ### Using Firestore Emulator
278
265
 
279
- For local development and testing:
266
+ For local development and manual testing:
280
267
 
281
268
  ```bash
282
269
  # Install Firebase CLI
@@ -284,9 +271,6 @@ npm install -g firebase-tools
284
271
 
285
272
  # Start emulator
286
273
  firebase emulators:start --only firestore
287
-
288
- # Or use the provided script
289
- ./scripts/start-emulator.sh
290
274
  ```
291
275
 
292
276
  Set environment variables:
@@ -296,6 +280,8 @@ export FIRESTORE_PROJECT_ID=test-project
296
280
  export FIRESTORE_EMULATOR_HOST=localhost:8080
297
281
  ```
298
282
 
283
+ E2E tests start the emulator automatically via Testcontainers.
284
+
299
285
  ## Examples
300
286
 
301
287
  ### Complete Shopping Cart Example
@@ -507,6 +493,54 @@ try {
507
493
  }
508
494
  ```
509
495
 
496
+ ## Observability
497
+
498
+ The event store supports optional logging and OpenTelemetry tracing.
499
+
500
+ ### Logging
501
+
502
+ Pass an optional logger compatible with Pino:
503
+
504
+ ```typescript
505
+ import pino from 'pino';
506
+
507
+ const eventStore = getFirestoreEventStore(firestore, {
508
+ observability: {
509
+ logger: pino({ level: 'debug' }),
510
+ },
511
+ });
512
+ ```
513
+
514
+ **Logging points:**
515
+
516
+ - `info`: Initialization
517
+ - `debug`: I/O operations (queries, transactions)
518
+ - `warn`: Version conflicts (recoverable)
519
+ - `error`: Failures before rethrowing
520
+
521
+ **Note:** Event payloads are never logged.
522
+
523
+ ### Tracing
524
+
525
+ The package uses `@opentelemetry/api` directly. Spans are created passively:
526
+
527
+ - If your application initializes OpenTelemetry, spans will be recorded
528
+ - If not, the tracing calls are no-ops with zero overhead
529
+
530
+ **Span names:**
531
+
532
+ - `emmett.firestore.read_stream`
533
+ - `emmett.firestore.append_to_stream`
534
+
535
+ **Attributes:**
536
+
537
+ - `emmett.stream_name`
538
+ - `emmett.event_count`
539
+ - `emmett.new_version`
540
+ - `emmett.created_new_stream`
541
+
542
+ To enable tracing, initialize OpenTelemetry in your application (this package never initializes tracing itself).
543
+
510
544
  ## TypeScript Support
511
545
 
512
546
  The package is written in TypeScript and includes full type definitions:
package/dist/index.d.mts CHANGED
@@ -1,9 +1,143 @@
1
1
  import { Firestore, Timestamp } from '@google-cloud/firestore';
2
- import { F as FirestoreEventStoreOptions, a as FirestoreEventStore, E as ExpectedStreamVersion } from './types-CHnx_sMk.mjs';
3
- export { A as AppendToStreamOptions, d as AppendToStreamResult, C as CollectionConfig, e as EventDocument, f as ExpectedVersionConflictError, b as FirestoreReadEvent, c as FirestoreReadEventMetadata, R as ReadStreamOptions, S as StreamMetadata } from './types-CHnx_sMk.mjs';
4
- import { STREAM_DOES_NOT_EXIST } from '@event-driven-io/emmett';
2
+ import { Event, ReadEvent, ReadEventMetadataWithGlobalPosition, ExpectedStreamVersion as ExpectedStreamVersion$1, STREAM_DOES_NOT_EXIST } from '@event-driven-io/emmett';
5
3
  export { NO_CONCURRENCY_CHECK, STREAM_DOES_NOT_EXIST, STREAM_EXISTS } from '@event-driven-io/emmett';
6
4
 
5
+ /**
6
+ * Minimal logger interface compatible with Pino and other loggers.
7
+ * All methods are optional to support varying logger implementations.
8
+ */
9
+ interface Logger {
10
+ debug?(msg: string, data?: unknown): void;
11
+ info?(msg: string, data?: unknown): void;
12
+ warn?(msg: string, data?: unknown): void;
13
+ error?(msg: string, err?: unknown): void;
14
+ }
15
+ /**
16
+ * Observability configuration options
17
+ */
18
+ interface ObservabilityOptions {
19
+ /** Optional logger instance. If not provided, no logging occurs. */
20
+ logger?: Logger;
21
+ }
22
+ /**
23
+ * Expected version for stream operations
24
+ * Uses Emmett's standard version constants for full compatibility
25
+ * - number | bigint: Expect specific version
26
+ * - STREAM_DOES_NOT_EXIST: Stream must not exist
27
+ * - STREAM_EXISTS: Stream must exist (any version)
28
+ * - NO_CONCURRENCY_CHECK: No version check
29
+ */
30
+ type ExpectedStreamVersion = ExpectedStreamVersion$1<bigint>;
31
+
32
+ /**
33
+ * Options for appending events to a stream
34
+ */
35
+ interface AppendToStreamOptions {
36
+ expectedStreamVersion?: ExpectedStreamVersion;
37
+ }
38
+ /**
39
+ * Result of appending events to a stream
40
+ */
41
+ interface AppendToStreamResult {
42
+ nextExpectedStreamVersion: bigint;
43
+ createdNewStream: boolean;
44
+ }
45
+ /**
46
+ * Options for reading events from a stream
47
+ */
48
+ interface ReadStreamOptions {
49
+ from?: bigint;
50
+ to?: bigint;
51
+ maxCount?: number;
52
+ }
53
+ /**
54
+ * Metadata stored in Firestore stream document
55
+ */
56
+ interface StreamMetadata {
57
+ version: number;
58
+ createdAt: Timestamp;
59
+ updatedAt: Timestamp;
60
+ }
61
+ /**
62
+ * Event document structure in Firestore
63
+ */
64
+ interface EventDocument {
65
+ type: string;
66
+ data: Record<string, unknown>;
67
+ metadata?: Record<string, unknown>;
68
+ timestamp: Timestamp;
69
+ globalPosition: number;
70
+ streamVersion: number;
71
+ }
72
+ /**
73
+ * Firestore-specific read event metadata
74
+ */
75
+ interface FirestoreReadEventMetadata extends ReadEventMetadataWithGlobalPosition {
76
+ streamName: string;
77
+ streamVersion: bigint;
78
+ timestamp: Date;
79
+ }
80
+ /**
81
+ * Firestore read event
82
+ */
83
+ type FirestoreReadEvent<EventType extends Event = Event> = ReadEvent<EventType, FirestoreReadEventMetadata>;
84
+ /**
85
+ * Collection configuration for Firestore event store
86
+ */
87
+ interface CollectionConfig {
88
+ streams: string;
89
+ counters: string;
90
+ }
91
+ /**
92
+ * Firestore event store options
93
+ */
94
+ interface FirestoreEventStoreOptions {
95
+ collections?: Partial<CollectionConfig>;
96
+ observability?: ObservabilityOptions;
97
+ }
98
+ /**
99
+ * Firestore event store interface
100
+ */
101
+ interface FirestoreEventStore {
102
+ /**
103
+ * The underlying Firestore instance
104
+ */
105
+ readonly firestore: Firestore;
106
+ /**
107
+ * Collection names configuration
108
+ */
109
+ readonly collections: CollectionConfig;
110
+ /**
111
+ * Read events from a stream
112
+ */
113
+ readStream<EventType extends Event>(streamName: string, options?: ReadStreamOptions): Promise<FirestoreReadEvent<EventType>[]>;
114
+ /**
115
+ * Aggregate stream by applying events to state
116
+ */
117
+ aggregateStream<State, EventType extends Event>(streamName: string, options: {
118
+ evolve: (state: State, event: FirestoreReadEvent<EventType>) => State;
119
+ initialState: () => State;
120
+ read?: ReadStreamOptions;
121
+ }): Promise<{
122
+ state: State;
123
+ currentStreamVersion: bigint;
124
+ streamExists: boolean;
125
+ }>;
126
+ /**
127
+ * Append events to a stream
128
+ */
129
+ appendToStream<EventType extends Event>(streamName: string, events: EventType[], options?: AppendToStreamOptions): Promise<AppendToStreamResult>;
130
+ }
131
+ /**
132
+ * Error thrown when expected version doesn't match current version
133
+ */
134
+ declare class ExpectedVersionConflictError extends Error {
135
+ readonly streamName: string;
136
+ readonly expected: ExpectedStreamVersion;
137
+ readonly actual: bigint | typeof STREAM_DOES_NOT_EXIST;
138
+ constructor(streamName: string, expected: ExpectedStreamVersion, actual: bigint | typeof STREAM_DOES_NOT_EXIST);
139
+ }
140
+
7
141
  /**
8
142
  * Factory function to create a Firestore event store
9
143
  *
@@ -82,4 +216,4 @@ declare function getCurrentStreamVersion(streamExists: boolean, version?: number
82
216
  */
83
217
  declare function calculateNextVersion(currentVersion: bigint | typeof STREAM_DOES_NOT_EXIST, eventCount: number): bigint;
84
218
 
85
- export { ExpectedStreamVersion, FirestoreEventStore, FirestoreEventStoreOptions, assertExpectedVersionMatchesCurrent, calculateNextVersion, getCurrentStreamVersion, getFirestoreEventStore, padVersion, parseStreamName, timestampToDate };
219
+ export { type AppendToStreamOptions, type AppendToStreamResult, type CollectionConfig, type EventDocument, type ExpectedStreamVersion, ExpectedVersionConflictError, type FirestoreEventStore, type FirestoreEventStoreOptions, type FirestoreReadEvent, type FirestoreReadEventMetadata, type Logger, type ObservabilityOptions, type ReadStreamOptions, type StreamMetadata, assertExpectedVersionMatchesCurrent, calculateNextVersion, getCurrentStreamVersion, getFirestoreEventStore, padVersion, parseStreamName, timestampToDate };
package/dist/index.d.ts CHANGED
@@ -1,9 +1,143 @@
1
1
  import { Firestore, Timestamp } from '@google-cloud/firestore';
2
- import { F as FirestoreEventStoreOptions, a as FirestoreEventStore, E as ExpectedStreamVersion } from './types-CHnx_sMk.js';
3
- export { A as AppendToStreamOptions, d as AppendToStreamResult, C as CollectionConfig, e as EventDocument, f as ExpectedVersionConflictError, b as FirestoreReadEvent, c as FirestoreReadEventMetadata, R as ReadStreamOptions, S as StreamMetadata } from './types-CHnx_sMk.js';
4
- import { STREAM_DOES_NOT_EXIST } from '@event-driven-io/emmett';
2
+ import { Event, ReadEvent, ReadEventMetadataWithGlobalPosition, ExpectedStreamVersion as ExpectedStreamVersion$1, STREAM_DOES_NOT_EXIST } from '@event-driven-io/emmett';
5
3
  export { NO_CONCURRENCY_CHECK, STREAM_DOES_NOT_EXIST, STREAM_EXISTS } from '@event-driven-io/emmett';
6
4
 
5
+ /**
6
+ * Minimal logger interface compatible with Pino and other loggers.
7
+ * All methods are optional to support varying logger implementations.
8
+ */
9
+ interface Logger {
10
+ debug?(msg: string, data?: unknown): void;
11
+ info?(msg: string, data?: unknown): void;
12
+ warn?(msg: string, data?: unknown): void;
13
+ error?(msg: string, err?: unknown): void;
14
+ }
15
+ /**
16
+ * Observability configuration options
17
+ */
18
+ interface ObservabilityOptions {
19
+ /** Optional logger instance. If not provided, no logging occurs. */
20
+ logger?: Logger;
21
+ }
22
+ /**
23
+ * Expected version for stream operations
24
+ * Uses Emmett's standard version constants for full compatibility
25
+ * - number | bigint: Expect specific version
26
+ * - STREAM_DOES_NOT_EXIST: Stream must not exist
27
+ * - STREAM_EXISTS: Stream must exist (any version)
28
+ * - NO_CONCURRENCY_CHECK: No version check
29
+ */
30
+ type ExpectedStreamVersion = ExpectedStreamVersion$1<bigint>;
31
+
32
+ /**
33
+ * Options for appending events to a stream
34
+ */
35
+ interface AppendToStreamOptions {
36
+ expectedStreamVersion?: ExpectedStreamVersion;
37
+ }
38
+ /**
39
+ * Result of appending events to a stream
40
+ */
41
+ interface AppendToStreamResult {
42
+ nextExpectedStreamVersion: bigint;
43
+ createdNewStream: boolean;
44
+ }
45
+ /**
46
+ * Options for reading events from a stream
47
+ */
48
+ interface ReadStreamOptions {
49
+ from?: bigint;
50
+ to?: bigint;
51
+ maxCount?: number;
52
+ }
53
+ /**
54
+ * Metadata stored in Firestore stream document
55
+ */
56
+ interface StreamMetadata {
57
+ version: number;
58
+ createdAt: Timestamp;
59
+ updatedAt: Timestamp;
60
+ }
61
+ /**
62
+ * Event document structure in Firestore
63
+ */
64
+ interface EventDocument {
65
+ type: string;
66
+ data: Record<string, unknown>;
67
+ metadata?: Record<string, unknown>;
68
+ timestamp: Timestamp;
69
+ globalPosition: number;
70
+ streamVersion: number;
71
+ }
72
+ /**
73
+ * Firestore-specific read event metadata
74
+ */
75
+ interface FirestoreReadEventMetadata extends ReadEventMetadataWithGlobalPosition {
76
+ streamName: string;
77
+ streamVersion: bigint;
78
+ timestamp: Date;
79
+ }
80
+ /**
81
+ * Firestore read event
82
+ */
83
+ type FirestoreReadEvent<EventType extends Event = Event> = ReadEvent<EventType, FirestoreReadEventMetadata>;
84
+ /**
85
+ * Collection configuration for Firestore event store
86
+ */
87
+ interface CollectionConfig {
88
+ streams: string;
89
+ counters: string;
90
+ }
91
+ /**
92
+ * Firestore event store options
93
+ */
94
+ interface FirestoreEventStoreOptions {
95
+ collections?: Partial<CollectionConfig>;
96
+ observability?: ObservabilityOptions;
97
+ }
98
+ /**
99
+ * Firestore event store interface
100
+ */
101
+ interface FirestoreEventStore {
102
+ /**
103
+ * The underlying Firestore instance
104
+ */
105
+ readonly firestore: Firestore;
106
+ /**
107
+ * Collection names configuration
108
+ */
109
+ readonly collections: CollectionConfig;
110
+ /**
111
+ * Read events from a stream
112
+ */
113
+ readStream<EventType extends Event>(streamName: string, options?: ReadStreamOptions): Promise<FirestoreReadEvent<EventType>[]>;
114
+ /**
115
+ * Aggregate stream by applying events to state
116
+ */
117
+ aggregateStream<State, EventType extends Event>(streamName: string, options: {
118
+ evolve: (state: State, event: FirestoreReadEvent<EventType>) => State;
119
+ initialState: () => State;
120
+ read?: ReadStreamOptions;
121
+ }): Promise<{
122
+ state: State;
123
+ currentStreamVersion: bigint;
124
+ streamExists: boolean;
125
+ }>;
126
+ /**
127
+ * Append events to a stream
128
+ */
129
+ appendToStream<EventType extends Event>(streamName: string, events: EventType[], options?: AppendToStreamOptions): Promise<AppendToStreamResult>;
130
+ }
131
+ /**
132
+ * Error thrown when expected version doesn't match current version
133
+ */
134
+ declare class ExpectedVersionConflictError extends Error {
135
+ readonly streamName: string;
136
+ readonly expected: ExpectedStreamVersion;
137
+ readonly actual: bigint | typeof STREAM_DOES_NOT_EXIST;
138
+ constructor(streamName: string, expected: ExpectedStreamVersion, actual: bigint | typeof STREAM_DOES_NOT_EXIST);
139
+ }
140
+
7
141
  /**
8
142
  * Factory function to create a Firestore event store
9
143
  *
@@ -82,4 +216,4 @@ declare function getCurrentStreamVersion(streamExists: boolean, version?: number
82
216
  */
83
217
  declare function calculateNextVersion(currentVersion: bigint | typeof STREAM_DOES_NOT_EXIST, eventCount: number): bigint;
84
218
 
85
- export { ExpectedStreamVersion, FirestoreEventStore, FirestoreEventStoreOptions, assertExpectedVersionMatchesCurrent, calculateNextVersion, getCurrentStreamVersion, getFirestoreEventStore, padVersion, parseStreamName, timestampToDate };
219
+ export { type AppendToStreamOptions, type AppendToStreamResult, type CollectionConfig, type EventDocument, type ExpectedStreamVersion, ExpectedVersionConflictError, type FirestoreEventStore, type FirestoreEventStoreOptions, type FirestoreReadEvent, type FirestoreReadEventMetadata, type Logger, type ObservabilityOptions, type ReadStreamOptions, type StreamMetadata, assertExpectedVersionMatchesCurrent, calculateNextVersion, getCurrentStreamVersion, getFirestoreEventStore, padVersion, parseStreamName, timestampToDate };
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ var api = require('@opentelemetry/api');
3
4
  var emmett = require('@event-driven-io/emmett');
4
5
 
5
- // src/eventStore/types.ts
6
+ // src/eventStore/firestoreEventStore.ts
6
7
  var ExpectedVersionConflictError = class _ExpectedVersionConflictError extends Error {
7
8
  constructor(streamName, expected, actual) {
8
9
  super(
@@ -83,6 +84,14 @@ function calculateNextVersion(currentVersion, eventCount) {
83
84
  }
84
85
 
85
86
  // src/eventStore/firestoreEventStore.ts
87
+ var tracer = api.trace.getTracer("@emmett-community/emmett-google-firestore");
88
+ function safeLog(logger, level, msg, data) {
89
+ if (!logger) return;
90
+ const logFn = logger[level];
91
+ if (typeof logFn === "function") {
92
+ logFn.call(logger, msg, data);
93
+ }
94
+ }
86
95
  var DEFAULT_COLLECTIONS = {
87
96
  streams: "streams",
88
97
  counters: "_counters"
@@ -94,39 +103,70 @@ var FirestoreEventStoreImpl = class {
94
103
  ...DEFAULT_COLLECTIONS,
95
104
  ...options.collections
96
105
  };
106
+ this.logger = options.observability?.logger;
107
+ safeLog(this.logger, "info", "FirestoreEventStore initialized");
97
108
  }
98
109
  collections;
110
+ logger;
99
111
  /**
100
112
  * Read events from a stream
101
113
  */
102
114
  async readStream(streamName, options = {}) {
103
- const { from, to, maxCount } = options;
104
- let query = this.firestore.collection(this.collections.streams).doc(streamName).collection("events").orderBy("streamVersion", "asc");
105
- if (from !== void 0) {
106
- query = query.where("streamVersion", ">=", Number(from));
107
- }
108
- if (to !== void 0) {
109
- query = query.where("streamVersion", "<=", Number(to));
110
- }
111
- if (maxCount !== void 0 && maxCount > 0) {
112
- query = query.limit(maxCount);
113
- }
114
- const snapshot = await query.get();
115
- return snapshot.docs.map((doc) => {
116
- const data = doc.data();
117
- return {
118
- type: data.type,
119
- data: data.data,
120
- metadata: {
121
- ...data.metadata,
122
- streamName,
123
- streamVersion: BigInt(data.streamVersion),
124
- streamPosition: BigInt(data.streamVersion),
125
- globalPosition: BigInt(data.globalPosition),
126
- timestamp: timestampToDate(data.timestamp)
127
- }
128
- };
115
+ const span = tracer.startSpan("emmett.firestore.read_stream", {
116
+ attributes: { "emmett.stream_name": streamName }
129
117
  });
118
+ try {
119
+ safeLog(this.logger, "debug", "Reading stream", {
120
+ streamName,
121
+ from: options.from?.toString(),
122
+ to: options.to?.toString(),
123
+ maxCount: options.maxCount
124
+ });
125
+ const { from, to, maxCount } = options;
126
+ let query = this.firestore.collection(this.collections.streams).doc(streamName).collection("events").orderBy("streamVersion", "asc");
127
+ if (from !== void 0) {
128
+ query = query.where("streamVersion", ">=", Number(from));
129
+ }
130
+ if (to !== void 0) {
131
+ query = query.where("streamVersion", "<=", Number(to));
132
+ }
133
+ if (maxCount !== void 0 && maxCount > 0) {
134
+ query = query.limit(maxCount);
135
+ }
136
+ const snapshot = await query.get();
137
+ const events = snapshot.docs.map((doc) => {
138
+ const data = doc.data();
139
+ return {
140
+ type: data.type,
141
+ data: data.data,
142
+ metadata: {
143
+ ...data.metadata,
144
+ streamName,
145
+ streamVersion: BigInt(data.streamVersion),
146
+ streamPosition: BigInt(data.streamVersion),
147
+ globalPosition: BigInt(data.globalPosition),
148
+ timestamp: timestampToDate(data.timestamp)
149
+ }
150
+ };
151
+ });
152
+ span.setAttribute("emmett.event_count", events.length);
153
+ span.setStatus({ code: api.SpanStatusCode.OK });
154
+ safeLog(this.logger, "debug", "Stream read completed", {
155
+ streamName,
156
+ eventCount: events.length
157
+ });
158
+ return events;
159
+ } catch (error) {
160
+ span.recordException(error);
161
+ span.setStatus({ code: api.SpanStatusCode.ERROR });
162
+ safeLog(this.logger, "error", "Failed to read stream", {
163
+ streamName,
164
+ error
165
+ });
166
+ throw error;
167
+ } finally {
168
+ span.end();
169
+ }
130
170
  }
131
171
  /**
132
172
  * Aggregate stream by applying events to state
@@ -147,21 +187,66 @@ var FirestoreEventStoreImpl = class {
147
187
  * Append events to a stream with optimistic concurrency control
148
188
  */
149
189
  async appendToStream(streamName, events, options = {}) {
150
- if (events.length === 0) {
151
- throw new Error("Cannot append empty event array");
152
- }
153
- const { expectedStreamVersion = emmett.NO_CONCURRENCY_CHECK } = options;
154
- return await this.firestore.runTransaction(async (transaction) => {
155
- return await this.appendToStreamInTransaction(
156
- transaction,
157
- streamName,
158
- events,
159
- expectedStreamVersion
160
- );
190
+ const span = tracer.startSpan("emmett.firestore.append_to_stream", {
191
+ attributes: {
192
+ "emmett.stream_name": streamName,
193
+ "emmett.event_count": events.length
194
+ }
161
195
  });
196
+ try {
197
+ if (events.length === 0) {
198
+ throw new Error("Cannot append empty event array");
199
+ }
200
+ const { expectedStreamVersion = emmett.NO_CONCURRENCY_CHECK } = options;
201
+ safeLog(this.logger, "debug", "Appending to stream", {
202
+ streamName,
203
+ eventCount: events.length,
204
+ eventTypes: events.map((e) => e.type),
205
+ expectedVersion: String(expectedStreamVersion)
206
+ });
207
+ const result = await this.firestore.runTransaction(async (transaction) => {
208
+ return await this.appendToStreamInTransaction(
209
+ transaction,
210
+ streamName,
211
+ events,
212
+ expectedStreamVersion
213
+ );
214
+ });
215
+ span.setAttribute("emmett.new_version", Number(result.nextExpectedStreamVersion));
216
+ span.setAttribute("emmett.created_new_stream", result.createdNewStream);
217
+ span.setStatus({ code: api.SpanStatusCode.OK });
218
+ safeLog(this.logger, "debug", "Append completed", {
219
+ streamName,
220
+ newVersion: result.nextExpectedStreamVersion.toString(),
221
+ createdNewStream: result.createdNewStream
222
+ });
223
+ return result;
224
+ } catch (error) {
225
+ span.recordException(error);
226
+ span.setStatus({ code: api.SpanStatusCode.ERROR });
227
+ if (error instanceof ExpectedVersionConflictError) {
228
+ safeLog(this.logger, "warn", "Version conflict during append", {
229
+ streamName,
230
+ expected: String(error.expected),
231
+ actual: String(error.actual)
232
+ });
233
+ } else {
234
+ safeLog(this.logger, "error", "Failed to append to stream", {
235
+ streamName,
236
+ error
237
+ });
238
+ }
239
+ throw error;
240
+ } finally {
241
+ span.end();
242
+ }
162
243
  }
163
244
  /**
164
245
  * Internal method to append events within a transaction
246
+ *
247
+ * Note: No separate span here - this method runs inside appendToStream's span,
248
+ * and Firestore transaction operations are atomic. The parent span captures
249
+ * the full transaction duration.
165
250
  */
166
251
  async appendToStreamInTransaction(transaction, streamName, events, expectedStreamVersion) {
167
252
  const streamRef = this.firestore.collection(this.collections.streams).doc(streamName);
@@ -172,6 +257,11 @@ var FirestoreEventStoreImpl = class {
172
257
  streamExists,
173
258
  streamData?.version
174
259
  );
260
+ safeLog(this.logger, "debug", "Read stream metadata", {
261
+ streamName,
262
+ exists: streamExists,
263
+ currentVersion: currentVersion === emmett.STREAM_DOES_NOT_EXIST ? "none" : currentVersion.toString()
264
+ });
175
265
  assertExpectedVersionMatchesCurrent(
176
266
  streamName,
177
267
  expectedStreamVersion,
@@ -208,6 +298,11 @@ var FirestoreEventStoreImpl = class {
208
298
  value: globalPosition,
209
299
  updatedAt: now
210
300
  });
301
+ safeLog(this.logger, "debug", "Events written to transaction", {
302
+ streamName,
303
+ count: events.length,
304
+ newVersion
305
+ });
211
306
  return {
212
307
  nextExpectedStreamVersion: BigInt(newVersion),
213
308
  createdNewStream: !streamExists