@durable-streams/client-conformance-tests 0.1.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.
Files changed (39) hide show
  1. package/README.md +451 -0
  2. package/dist/adapters/typescript-adapter.d.ts +1 -0
  3. package/dist/adapters/typescript-adapter.js +586 -0
  4. package/dist/benchmark-runner-C_Yghc8f.js +1333 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +265 -0
  7. package/dist/index.d.ts +508 -0
  8. package/dist/index.js +4 -0
  9. package/dist/protocol-DyEvTHPF.d.ts +472 -0
  10. package/dist/protocol-qb83AeUH.js +120 -0
  11. package/dist/protocol.d.ts +2 -0
  12. package/dist/protocol.js +3 -0
  13. package/package.json +53 -0
  14. package/src/adapters/typescript-adapter.ts +848 -0
  15. package/src/benchmark-runner.ts +860 -0
  16. package/src/benchmark-scenarios.ts +311 -0
  17. package/src/cli.ts +294 -0
  18. package/src/index.ts +50 -0
  19. package/src/protocol.ts +656 -0
  20. package/src/runner.ts +1191 -0
  21. package/src/test-cases.ts +475 -0
  22. package/test-cases/consumer/cache-headers.yaml +150 -0
  23. package/test-cases/consumer/error-handling.yaml +108 -0
  24. package/test-cases/consumer/message-ordering.yaml +209 -0
  25. package/test-cases/consumer/offset-handling.yaml +209 -0
  26. package/test-cases/consumer/offset-resumption.yaml +197 -0
  27. package/test-cases/consumer/read-catchup.yaml +173 -0
  28. package/test-cases/consumer/read-longpoll.yaml +132 -0
  29. package/test-cases/consumer/read-sse.yaml +145 -0
  30. package/test-cases/consumer/retry-resilience.yaml +160 -0
  31. package/test-cases/consumer/streaming-equivalence.yaml +226 -0
  32. package/test-cases/lifecycle/dynamic-headers.yaml +147 -0
  33. package/test-cases/lifecycle/headers-params.yaml +117 -0
  34. package/test-cases/lifecycle/stream-lifecycle.yaml +148 -0
  35. package/test-cases/producer/append-data.yaml +142 -0
  36. package/test-cases/producer/batching.yaml +112 -0
  37. package/test-cases/producer/create-stream.yaml +87 -0
  38. package/test-cases/producer/error-handling.yaml +90 -0
  39. package/test-cases/producer/sequence-ordering.yaml +148 -0
@@ -0,0 +1,586 @@
1
+ #!/usr/bin/env node
2
+ import { ErrorCodes, decodeBase64, parseCommand, serializeResult } from "../protocol-qb83AeUH.js";
3
+ import { createInterface } from "node:readline";
4
+ import { DurableStream, DurableStreamError, FetchError, stream } from "@durable-streams/client";
5
+
6
+ //#region src/adapters/typescript-adapter.ts
7
+ const CLIENT_VERSION = `0.0.1`;
8
+ let serverUrl = ``;
9
+ const streamContentTypes = new Map();
10
+ const dynamicHeaders = new Map();
11
+ const dynamicParams = new Map();
12
+ /** Resolve dynamic headers, returning both the header function map and tracked values */
13
+ function resolveDynamicHeaders() {
14
+ const headers = {};
15
+ const values = {};
16
+ for (const [name, config] of dynamicHeaders.entries()) {
17
+ let value;
18
+ switch (config.type) {
19
+ case `counter`:
20
+ config.counter++;
21
+ value = config.counter.toString();
22
+ break;
23
+ case `timestamp`:
24
+ value = Date.now().toString();
25
+ break;
26
+ case `token`:
27
+ value = config.tokenValue ?? ``;
28
+ break;
29
+ }
30
+ values[name] = value;
31
+ const capturedValue = value;
32
+ headers[name] = () => capturedValue;
33
+ }
34
+ return {
35
+ headers,
36
+ values
37
+ };
38
+ }
39
+ /** Resolve dynamic params */
40
+ function resolveDynamicParams() {
41
+ const params = {};
42
+ const values = {};
43
+ for (const [name, config] of dynamicParams.entries()) {
44
+ let value;
45
+ switch (config.type) {
46
+ case `counter`:
47
+ config.counter++;
48
+ value = config.counter.toString();
49
+ break;
50
+ case `timestamp`:
51
+ value = Date.now().toString();
52
+ break;
53
+ default: value = ``;
54
+ }
55
+ values[name] = value;
56
+ const capturedValue = value;
57
+ params[name] = () => capturedValue;
58
+ }
59
+ return {
60
+ params,
61
+ values
62
+ };
63
+ }
64
+ async function handleCommand(command) {
65
+ switch (command.type) {
66
+ case `init`: {
67
+ serverUrl = command.serverUrl;
68
+ streamContentTypes.clear();
69
+ dynamicHeaders.clear();
70
+ dynamicParams.clear();
71
+ return {
72
+ type: `init`,
73
+ success: true,
74
+ clientName: `@durable-streams/client`,
75
+ clientVersion: CLIENT_VERSION,
76
+ features: {
77
+ batching: true,
78
+ sse: true,
79
+ longPoll: true,
80
+ streaming: true,
81
+ dynamicHeaders: true
82
+ }
83
+ };
84
+ }
85
+ case `create`: try {
86
+ const url = `${serverUrl}${command.path}`;
87
+ const contentType = command.contentType ?? `application/octet-stream`;
88
+ let alreadyExists = false;
89
+ try {
90
+ await DurableStream.head({ url });
91
+ alreadyExists = true;
92
+ } catch {}
93
+ const ds = await DurableStream.create({
94
+ url,
95
+ contentType,
96
+ ttlSeconds: command.ttlSeconds,
97
+ expiresAt: command.expiresAt,
98
+ headers: command.headers
99
+ });
100
+ streamContentTypes.set(command.path, contentType);
101
+ const head = await ds.head();
102
+ return {
103
+ type: `create`,
104
+ success: true,
105
+ status: alreadyExists ? 200 : 201,
106
+ offset: head.offset
107
+ };
108
+ } catch (err) {
109
+ return errorResult(`create`, err);
110
+ }
111
+ case `connect`: try {
112
+ const url = `${serverUrl}${command.path}`;
113
+ const ds = await DurableStream.connect({
114
+ url,
115
+ headers: command.headers
116
+ });
117
+ const head = await ds.head();
118
+ if (head.contentType) streamContentTypes.set(command.path, head.contentType);
119
+ return {
120
+ type: `connect`,
121
+ success: true,
122
+ status: 200,
123
+ offset: head.offset
124
+ };
125
+ } catch (err) {
126
+ return errorResult(`connect`, err);
127
+ }
128
+ case `append`: try {
129
+ const url = `${serverUrl}${command.path}`;
130
+ const contentType = streamContentTypes.get(command.path) ?? `application/octet-stream`;
131
+ const { headers: dynamicHdrs, values: headersSent } = resolveDynamicHeaders();
132
+ const { values: paramsSent } = resolveDynamicParams();
133
+ const mergedHeaders = {
134
+ ...dynamicHdrs,
135
+ ...command.headers
136
+ };
137
+ const ds = new DurableStream({
138
+ url,
139
+ headers: mergedHeaders,
140
+ contentType
141
+ });
142
+ let body;
143
+ if (command.binary) body = decodeBase64(command.data);
144
+ else body = command.data;
145
+ await ds.append(body, { seq: command.seq?.toString() });
146
+ const head = await ds.head();
147
+ return {
148
+ type: `append`,
149
+ success: true,
150
+ status: 200,
151
+ offset: head.offset,
152
+ headersSent: Object.keys(headersSent).length > 0 ? headersSent : void 0,
153
+ paramsSent: Object.keys(paramsSent).length > 0 ? paramsSent : void 0
154
+ };
155
+ } catch (err) {
156
+ return errorResult(`append`, err);
157
+ }
158
+ case `read`: try {
159
+ const url = `${serverUrl}${command.path}`;
160
+ const { headers: dynamicHdrs, values: headersSent } = resolveDynamicHeaders();
161
+ const { values: paramsSent } = resolveDynamicParams();
162
+ const mergedHeaders = {
163
+ ...dynamicHdrs,
164
+ ...command.headers
165
+ };
166
+ let live;
167
+ if (command.live === `long-poll`) live = `long-poll`;
168
+ else if (command.live === `sse`) live = `sse`;
169
+ else live = false;
170
+ const abortController = new AbortController();
171
+ const timeoutMs = command.timeoutMs ?? 5e3;
172
+ const timeoutId = setTimeout(() => {
173
+ abortController.abort();
174
+ }, timeoutMs);
175
+ let response;
176
+ try {
177
+ response = await stream({
178
+ url,
179
+ offset: command.offset,
180
+ live,
181
+ headers: mergedHeaders,
182
+ signal: abortController.signal
183
+ });
184
+ } catch (err) {
185
+ clearTimeout(timeoutId);
186
+ if (abortController.signal.aborted) return {
187
+ type: `read`,
188
+ success: true,
189
+ status: 200,
190
+ chunks: [],
191
+ offset: command.offset ?? `-1`,
192
+ upToDate: true,
193
+ headersSent: Object.keys(headersSent).length > 0 ? headersSent : void 0,
194
+ paramsSent: Object.keys(paramsSent).length > 0 ? paramsSent : void 0
195
+ };
196
+ throw err;
197
+ }
198
+ clearTimeout(timeoutId);
199
+ const chunks = [];
200
+ let finalOffset = command.offset ?? response.offset;
201
+ let upToDate = response.upToDate;
202
+ const maxChunks = command.maxChunks ?? 100;
203
+ if (!live) try {
204
+ const data = await response.body();
205
+ if (data.length > 0) chunks.push({
206
+ data: new TextDecoder().decode(data),
207
+ offset: response.offset
208
+ });
209
+ finalOffset = response.offset;
210
+ upToDate = response.upToDate;
211
+ } catch {}
212
+ else {
213
+ const decoder = new TextDecoder();
214
+ const startTime = Date.now();
215
+ let chunkCount = 0;
216
+ let done = false;
217
+ await new Promise((resolve) => {
218
+ const subscriptionTimeoutId = setTimeout(() => {
219
+ done = true;
220
+ abortController.abort();
221
+ upToDate = response.upToDate || true;
222
+ finalOffset = response.offset;
223
+ resolve();
224
+ }, timeoutMs);
225
+ const unsubscribe = response.subscribeBytes(async (chunk) => {
226
+ if (done || chunkCount >= maxChunks) return;
227
+ if (Date.now() - startTime > timeoutMs) {
228
+ done = true;
229
+ resolve();
230
+ return;
231
+ }
232
+ const hasData = chunk.data.length > 0;
233
+ if (hasData) {
234
+ chunks.push({
235
+ data: decoder.decode(chunk.data),
236
+ offset: chunk.offset
237
+ });
238
+ chunkCount++;
239
+ }
240
+ finalOffset = chunk.offset;
241
+ upToDate = chunk.upToDate;
242
+ if (command.waitForUpToDate && chunk.upToDate) {
243
+ done = true;
244
+ clearTimeout(subscriptionTimeoutId);
245
+ resolve();
246
+ return;
247
+ }
248
+ if (chunkCount >= maxChunks) {
249
+ done = true;
250
+ clearTimeout(subscriptionTimeoutId);
251
+ resolve();
252
+ }
253
+ await Promise.resolve();
254
+ });
255
+ response.closed.then(() => {
256
+ if (!done) {
257
+ done = true;
258
+ clearTimeout(subscriptionTimeoutId);
259
+ upToDate = response.upToDate;
260
+ finalOffset = response.offset;
261
+ resolve();
262
+ }
263
+ }).catch(() => {
264
+ if (!done) {
265
+ done = true;
266
+ clearTimeout(subscriptionTimeoutId);
267
+ resolve();
268
+ }
269
+ });
270
+ });
271
+ }
272
+ response.cancel();
273
+ return {
274
+ type: `read`,
275
+ success: true,
276
+ status: 200,
277
+ chunks,
278
+ offset: finalOffset,
279
+ upToDate,
280
+ headersSent: Object.keys(headersSent).length > 0 ? headersSent : void 0,
281
+ paramsSent: Object.keys(paramsSent).length > 0 ? paramsSent : void 0
282
+ };
283
+ } catch (err) {
284
+ return errorResult(`read`, err);
285
+ }
286
+ case `head`: try {
287
+ const url = `${serverUrl}${command.path}`;
288
+ const result = await DurableStream.head({
289
+ url,
290
+ headers: command.headers
291
+ });
292
+ if (result.contentType) streamContentTypes.set(command.path, result.contentType);
293
+ return {
294
+ type: `head`,
295
+ success: true,
296
+ status: 200,
297
+ offset: result.offset,
298
+ contentType: result.contentType
299
+ };
300
+ } catch (err) {
301
+ return errorResult(`head`, err);
302
+ }
303
+ case `delete`: try {
304
+ const url = `${serverUrl}${command.path}`;
305
+ await DurableStream.delete({
306
+ url,
307
+ headers: command.headers
308
+ });
309
+ streamContentTypes.delete(command.path);
310
+ return {
311
+ type: `delete`,
312
+ success: true,
313
+ status: 200
314
+ };
315
+ } catch (err) {
316
+ return errorResult(`delete`, err);
317
+ }
318
+ case `shutdown`: return {
319
+ type: `shutdown`,
320
+ success: true
321
+ };
322
+ case `benchmark`: return handleBenchmark(command);
323
+ case `set-dynamic-header`: {
324
+ dynamicHeaders.set(command.name, {
325
+ type: command.valueType,
326
+ counter: 0,
327
+ tokenValue: command.initialValue
328
+ });
329
+ return {
330
+ type: `set-dynamic-header`,
331
+ success: true
332
+ };
333
+ }
334
+ case `set-dynamic-param`: {
335
+ dynamicParams.set(command.name, {
336
+ type: command.valueType,
337
+ counter: 0
338
+ });
339
+ return {
340
+ type: `set-dynamic-param`,
341
+ success: true
342
+ };
343
+ }
344
+ case `clear-dynamic`: {
345
+ dynamicHeaders.clear();
346
+ dynamicParams.clear();
347
+ return {
348
+ type: `clear-dynamic`,
349
+ success: true
350
+ };
351
+ }
352
+ default: return {
353
+ type: `error`,
354
+ success: false,
355
+ commandType: command.type,
356
+ errorCode: ErrorCodes.NOT_SUPPORTED,
357
+ message: `Unknown command type: ${command.type}`
358
+ };
359
+ }
360
+ }
361
+ function errorResult(commandType, err) {
362
+ if (err instanceof DurableStreamError) {
363
+ let errorCode = ErrorCodes.INTERNAL_ERROR;
364
+ let status;
365
+ if (err.code === `NOT_FOUND`) {
366
+ errorCode = ErrorCodes.NOT_FOUND;
367
+ status = 404;
368
+ } else if (err.code === `CONFLICT_EXISTS`) {
369
+ errorCode = ErrorCodes.CONFLICT;
370
+ status = 409;
371
+ } else if (err.code === `CONFLICT_SEQ`) {
372
+ errorCode = ErrorCodes.SEQUENCE_CONFLICT;
373
+ status = 409;
374
+ } else if (err.code === `BAD_REQUEST`) {
375
+ errorCode = ErrorCodes.INVALID_OFFSET;
376
+ status = 400;
377
+ }
378
+ return {
379
+ type: `error`,
380
+ success: false,
381
+ commandType,
382
+ status,
383
+ errorCode,
384
+ message: err.message
385
+ };
386
+ }
387
+ if (err instanceof FetchError) {
388
+ let errorCode;
389
+ const msg = err.message.toLowerCase();
390
+ if (err.status === 404) errorCode = ErrorCodes.NOT_FOUND;
391
+ else if (err.status === 409) if (msg.includes(`sequence`)) errorCode = ErrorCodes.SEQUENCE_CONFLICT;
392
+ else errorCode = ErrorCodes.CONFLICT;
393
+ else if (err.status === 400) if (msg.includes(`offset`) || msg.includes(`invalid`)) errorCode = ErrorCodes.INVALID_OFFSET;
394
+ else errorCode = ErrorCodes.UNEXPECTED_STATUS;
395
+ else errorCode = ErrorCodes.UNEXPECTED_STATUS;
396
+ return {
397
+ type: `error`,
398
+ success: false,
399
+ commandType,
400
+ status: err.status,
401
+ errorCode,
402
+ message: err.message
403
+ };
404
+ }
405
+ if (err instanceof Error) {
406
+ if (err.message.includes(`ECONNREFUSED`) || err.message.includes(`fetch`)) return {
407
+ type: `error`,
408
+ success: false,
409
+ commandType,
410
+ errorCode: ErrorCodes.NETWORK_ERROR,
411
+ message: err.message
412
+ };
413
+ return {
414
+ type: `error`,
415
+ success: false,
416
+ commandType,
417
+ errorCode: ErrorCodes.INTERNAL_ERROR,
418
+ message: err.message
419
+ };
420
+ }
421
+ return {
422
+ type: `error`,
423
+ success: false,
424
+ commandType,
425
+ errorCode: ErrorCodes.INTERNAL_ERROR,
426
+ message: String(err)
427
+ };
428
+ }
429
+ /**
430
+ * Handle benchmark commands with high-resolution timing.
431
+ */
432
+ async function handleBenchmark(command) {
433
+ const { iterationId, operation } = command;
434
+ try {
435
+ const startTime = process.hrtime.bigint();
436
+ const metrics = {};
437
+ switch (operation.op) {
438
+ case `append`: {
439
+ const url = `${serverUrl}${operation.path}`;
440
+ const contentType = streamContentTypes.get(operation.path) ?? `application/octet-stream`;
441
+ const ds = new DurableStream({
442
+ url,
443
+ contentType
444
+ });
445
+ const payload = new Uint8Array(operation.size).fill(42);
446
+ await ds.append(payload);
447
+ metrics.bytesTransferred = operation.size;
448
+ break;
449
+ }
450
+ case `read`: {
451
+ const url = `${serverUrl}${operation.path}`;
452
+ const res = await stream({
453
+ url,
454
+ offset: operation.offset,
455
+ live: false
456
+ });
457
+ const data = await res.body();
458
+ metrics.bytesTransferred = data.length;
459
+ break;
460
+ }
461
+ case `roundtrip`: {
462
+ const url = `${serverUrl}${operation.path}`;
463
+ const contentType = operation.contentType ?? `application/octet-stream`;
464
+ const ds = await DurableStream.create({
465
+ url,
466
+ contentType
467
+ });
468
+ const payload = new Uint8Array(operation.size).fill(42);
469
+ const readPromise = (async () => {
470
+ const res = await ds.stream({ live: operation.live ?? `long-poll` });
471
+ return new Promise((resolve) => {
472
+ const unsubscribe = res.subscribeBytes(async (chunk) => {
473
+ if (chunk.data.length > 0) {
474
+ unsubscribe();
475
+ res.cancel();
476
+ resolve(chunk.data);
477
+ }
478
+ });
479
+ });
480
+ })();
481
+ await ds.append(payload);
482
+ const readData = await readPromise;
483
+ metrics.bytesTransferred = operation.size + readData.length;
484
+ break;
485
+ }
486
+ case `create`: {
487
+ const url = `${serverUrl}${operation.path}`;
488
+ await DurableStream.create({
489
+ url,
490
+ contentType: operation.contentType ?? `application/octet-stream`
491
+ });
492
+ break;
493
+ }
494
+ case `throughput_append`: {
495
+ const url = `${serverUrl}${operation.path}`;
496
+ const contentType = streamContentTypes.get(operation.path) ?? `application/octet-stream`;
497
+ try {
498
+ await DurableStream.create({
499
+ url,
500
+ contentType
501
+ });
502
+ } catch {}
503
+ const ds = new DurableStream({
504
+ url,
505
+ contentType
506
+ });
507
+ const payload = new Uint8Array(operation.size).fill(42);
508
+ await Promise.all(Array.from({ length: operation.count }, () => ds.append(payload)));
509
+ metrics.bytesTransferred = operation.count * operation.size;
510
+ metrics.messagesProcessed = operation.count;
511
+ break;
512
+ }
513
+ case `throughput_read`: {
514
+ const url = `${serverUrl}${operation.path}`;
515
+ const res = await stream({
516
+ url,
517
+ live: false
518
+ });
519
+ let count = 0;
520
+ let bytes = 0;
521
+ for await (const msg of res.jsonStream()) {
522
+ count++;
523
+ bytes += JSON.stringify(msg).length;
524
+ }
525
+ metrics.bytesTransferred = bytes;
526
+ metrics.messagesProcessed = count;
527
+ break;
528
+ }
529
+ default: return {
530
+ type: `error`,
531
+ success: false,
532
+ commandType: `benchmark`,
533
+ errorCode: ErrorCodes.NOT_SUPPORTED,
534
+ message: `Unknown benchmark operation: ${operation.op}`
535
+ };
536
+ }
537
+ const endTime = process.hrtime.bigint();
538
+ const durationNs = endTime - startTime;
539
+ return {
540
+ type: `benchmark`,
541
+ success: true,
542
+ iterationId,
543
+ durationNs: durationNs.toString(),
544
+ metrics
545
+ };
546
+ } catch (err) {
547
+ return {
548
+ type: `error`,
549
+ success: false,
550
+ commandType: `benchmark`,
551
+ errorCode: ErrorCodes.INTERNAL_ERROR,
552
+ message: err instanceof Error ? err.message : String(err)
553
+ };
554
+ }
555
+ }
556
+ async function main() {
557
+ const rl = createInterface({
558
+ input: process.stdin,
559
+ output: process.stdout,
560
+ terminal: false
561
+ });
562
+ for await (const line of rl) {
563
+ if (!line.trim()) continue;
564
+ try {
565
+ const command = parseCommand(line);
566
+ const result = await handleCommand(command);
567
+ console.log(serializeResult(result));
568
+ if (command.type === `shutdown`) break;
569
+ } catch (err) {
570
+ console.log(serializeResult({
571
+ type: `error`,
572
+ success: false,
573
+ commandType: `init`,
574
+ errorCode: ErrorCodes.PARSE_ERROR,
575
+ message: `Failed to parse command: ${err}`
576
+ }));
577
+ }
578
+ }
579
+ process.exit(0);
580
+ }
581
+ main().catch((err) => {
582
+ console.error(`Fatal error:`, err);
583
+ process.exit(1);
584
+ });
585
+
586
+ //#endregion