@milaboratories/pl-client 2.4.10

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 (64) hide show
  1. package/README.md +52 -0
  2. package/dist/index.cjs +14527 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.js +14426 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +49 -0
  7. package/src/core/auth.ts +27 -0
  8. package/src/core/client.test.ts +47 -0
  9. package/src/core/client.ts +302 -0
  10. package/src/core/config.test.ts +19 -0
  11. package/src/core/config.ts +197 -0
  12. package/src/core/default_client.ts +161 -0
  13. package/src/core/driver.ts +30 -0
  14. package/src/core/error.test.ts +14 -0
  15. package/src/core/errors.ts +84 -0
  16. package/src/core/http.ts +178 -0
  17. package/src/core/ll_client.test.ts +111 -0
  18. package/src/core/ll_client.ts +228 -0
  19. package/src/core/ll_transaction.test.ts +152 -0
  20. package/src/core/ll_transaction.ts +333 -0
  21. package/src/core/transaction.test.ts +173 -0
  22. package/src/core/transaction.ts +730 -0
  23. package/src/core/type_conversion.ts +121 -0
  24. package/src/core/types.test.ts +22 -0
  25. package/src/core/types.ts +223 -0
  26. package/src/core/unauth_client.test.ts +21 -0
  27. package/src/core/unauth_client.ts +48 -0
  28. package/src/helpers/pl.ts +141 -0
  29. package/src/helpers/poll.ts +178 -0
  30. package/src/helpers/rich_resource_types.test.ts +22 -0
  31. package/src/helpers/rich_resource_types.ts +84 -0
  32. package/src/helpers/smart_accessors.ts +146 -0
  33. package/src/helpers/state_helpers.ts +5 -0
  34. package/src/helpers/tx_helpers.ts +24 -0
  35. package/src/index.ts +14 -0
  36. package/src/proto/github.com/googleapis/googleapis/google/rpc/status.ts +125 -0
  37. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.client.ts +45 -0
  38. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/downloadapi/protocol.ts +271 -0
  39. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/lsapi/protocol.client.ts +51 -0
  40. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/lsapi/protocol.ts +380 -0
  41. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/progressapi/protocol.client.ts +59 -0
  42. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/progressapi/protocol.ts +450 -0
  43. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/streamingapi/protocol.client.ts +148 -0
  44. package/src/proto/github.com/milaboratory/pl/controllers/shared/grpc/streamingapi/protocol.ts +706 -0
  45. package/src/proto/github.com/milaboratory/pl/plapi/plapiproto/api.client.ts +406 -0
  46. package/src/proto/github.com/milaboratory/pl/plapi/plapiproto/api.ts +12636 -0
  47. package/src/proto/github.com/milaboratory/pl/plapi/plapiproto/api_types.ts +1384 -0
  48. package/src/proto/github.com/milaboratory/pl/plapi/plapiproto/base_types.ts +181 -0
  49. package/src/proto/github.com/milaboratory/pl/plapi/plapiproto/import.ts +251 -0
  50. package/src/proto/github.com/milaboratory/pl/plapi/plapiproto/resource_types.ts +693 -0
  51. package/src/proto/google/api/http.ts +687 -0
  52. package/src/proto/google/protobuf/any.ts +326 -0
  53. package/src/proto/google/protobuf/descriptor.ts +4502 -0
  54. package/src/proto/google/protobuf/duration.ts +230 -0
  55. package/src/proto/google/protobuf/empty.ts +81 -0
  56. package/src/proto/google/protobuf/struct.ts +482 -0
  57. package/src/proto/google/protobuf/timestamp.ts +287 -0
  58. package/src/proto/google/protobuf/wrappers.ts +751 -0
  59. package/src/test/test_config.test.ts +6 -0
  60. package/src/test/test_config.ts +166 -0
  61. package/src/util/branding.ts +4 -0
  62. package/src/util/pl.ts +11 -0
  63. package/src/util/util.test.ts +10 -0
  64. package/src/util/util.ts +9 -0
@@ -0,0 +1,333 @@
1
+ import {
2
+ TxAPI_ClientMessage,
3
+ TxAPI_ServerMessage
4
+ } from '../proto/github.com/milaboratory/pl/plapi/plapiproto/api';
5
+ import { DuplexStreamingCall } from '@protobuf-ts/runtime-rpc';
6
+ import Denque from 'denque';
7
+ import { Status } from '../proto/github.com/googleapis/googleapis/google/rpc/status';
8
+ import {
9
+ PlErrorCodeNotFound,
10
+ RecoverablePlError,
11
+ rethrowMeaningfulError,
12
+ UnrecoverablePlError
13
+ } from './errors';
14
+
15
+ export type ClientMessageRequest = TxAPI_ClientMessage['request'];
16
+
17
+ export type ServerMessageResponse = TxAPI_ServerMessage['response'];
18
+
19
+ type TxStream = DuplexStreamingCall<TxAPI_ClientMessage, TxAPI_ServerMessage>;
20
+
21
+ export type OneOfKind<T extends { oneofKind: unknown }, Kind extends T['oneofKind']> = Extract<
22
+ T,
23
+ { oneofKind: Kind }
24
+ >;
25
+
26
+ interface SingleResponseHandler<Kind extends ServerMessageResponse['oneofKind']> {
27
+ kind: Kind;
28
+ expectMultiResponse: false;
29
+ resolve: (v: OneOfKind<ServerMessageResponse, Kind>) => void;
30
+ reject: (e: Error) => void;
31
+ }
32
+
33
+ interface MultiResponseHandler<Kind extends ServerMessageResponse['oneofKind']> {
34
+ kind: Kind;
35
+ expectMultiResponse: true;
36
+ resolve: (v: OneOfKind<ServerMessageResponse, Kind>[]) => void;
37
+ reject: (e: Error) => void;
38
+ }
39
+
40
+ type AnySingleResponseHandler = SingleResponseHandler<ServerMessageResponse['oneofKind']>;
41
+
42
+ type AnyMultiResponseHandler = MultiResponseHandler<ServerMessageResponse['oneofKind']>;
43
+
44
+ type AnyResponseHandler =
45
+ | SingleResponseHandler<ServerMessageResponse['oneofKind']>
46
+ | MultiResponseHandler<ServerMessageResponse['oneofKind']>;
47
+
48
+ function createResponseHandler<Kind extends ServerMessageResponse['oneofKind']>(
49
+ kind: Kind,
50
+ expectMultiResponse: boolean,
51
+ resolve:
52
+ | ((v: OneOfKind<ServerMessageResponse, Kind>) => void)
53
+ | ((v: OneOfKind<ServerMessageResponse, Kind>[]) => void),
54
+ reject: (e: Error) => void
55
+ ): AnyResponseHandler {
56
+ return { kind, expectMultiResponse, resolve, reject } as AnyResponseHandler;
57
+ }
58
+
59
+ function isRecoverable(status: Status): boolean {
60
+ return status.code === PlErrorCodeNotFound;
61
+ }
62
+
63
+ export class RethrowError extends Error {
64
+ constructor(public readonly rethrowLambda: () => never) {
65
+ super('Rethrow error, you should never see this one.');
66
+ }
67
+ }
68
+
69
+ export class LLPlTransaction {
70
+ /** Bidirectional channel through which transaction communicates with the server */
71
+ private readonly stream: TxStream;
72
+
73
+ /** Used to abort ongoing transaction stream */
74
+ private readonly abortController = new AbortController();
75
+
76
+ /** Counter of sent requests, used to calculate which future response will correspond to this request.
77
+ * Incremented on each sent request. */
78
+ private requestIdxCounter = 0;
79
+
80
+ /** Queue from which incoming message processor takes handlers to which pass incoming messages */
81
+ private readonly responseHandlerQueue = new Denque<AnyResponseHandler>();
82
+
83
+ /** Each new resource, created by the transaction, is assigned with virtual (local) resource id, to make it possible
84
+ * to populate its fields without awaiting actual resource id. This counter tracks those ids on client side, the
85
+ * same way it is tracked on the server, so client can synchronously return such ids to the user. */
86
+ private localResourceIdCounter = 0n;
87
+
88
+ /** Switches to true, when this transaction closes due to normal or exceptional conditions. Prevents any new messages
89
+ * to be sent to the stream. */
90
+ private closed = false;
91
+ /** Whether the outgoing stream was already closed. */
92
+ private completed = false;
93
+
94
+ /** If this transaction was terminated due to error, this is a generator to create new errors if corresponding response is required. */
95
+ private errorFactory?: () => never;
96
+
97
+ /** Timestamp when transaction was opened */
98
+ private readonly openTimestamp = Date.now();
99
+
100
+ private readonly incomingProcessorResult: Promise<(() => never) | null>;
101
+
102
+ constructor(streamFactory: (abortSignal: AbortSignal) => TxStream) {
103
+ this.stream = streamFactory(this.abortController.signal);
104
+
105
+ // Starting incoming event processor
106
+ this.incomingProcessorResult = this.incomingEventProcessor();
107
+ }
108
+
109
+ private assignErrorFactoryIfNotSet(
110
+ errorFactory: () => never,
111
+ reject?: (e: Error) => void
112
+ ): () => never {
113
+ if (reject !== undefined) reject(new RethrowError(errorFactory));
114
+ if (this.errorFactory) return errorFactory;
115
+ this.errorFactory = errorFactory;
116
+ return errorFactory;
117
+ }
118
+
119
+ private async incomingEventProcessor(): Promise<(() => never) | null> {
120
+ /** Counter of received responses, used to check consistency of responses.
121
+ * Increments on each received message. */
122
+ let expectedId = -1;
123
+
124
+ // defined externally to make possible to communicate any processing errors
125
+ // to the specific request on which it happened
126
+ let currentHandler: AnyResponseHandler | undefined = undefined;
127
+ let responseAggregator: ServerMessageResponse[] | undefined = undefined;
128
+ try {
129
+ for await (const message of this.stream.responses) {
130
+ if (currentHandler === undefined) {
131
+ currentHandler = this.responseHandlerQueue.shift();
132
+
133
+ if (currentHandler === undefined) {
134
+ this.assignErrorFactoryIfNotSet(() => {
135
+ throw new Error(`orphan incoming message`);
136
+ });
137
+ break;
138
+ }
139
+
140
+ // allocating response aggregator array
141
+ if (currentHandler.expectMultiResponse) responseAggregator = [];
142
+
143
+ expectedId++;
144
+ }
145
+
146
+ if (message.requestId !== expectedId) {
147
+ const errorMessage = `out of order messages, ${message.requestId} !== ${expectedId}`;
148
+ this.assignErrorFactoryIfNotSet(() => {
149
+ throw new Error(errorMessage);
150
+ });
151
+ break;
152
+ }
153
+
154
+ if (message.error !== undefined) {
155
+ const status = message.error;
156
+
157
+ if (isRecoverable(status)) {
158
+ currentHandler.reject(
159
+ new RethrowError(() => {
160
+ throw new RecoverablePlError(status);
161
+ })
162
+ );
163
+ currentHandler = undefined;
164
+
165
+ if (message.multiMessage !== undefined && !message.multiMessage.isLast) {
166
+ this.assignErrorFactoryIfNotSet(() => {
167
+ throw new Error('Unexpected message sequence.');
168
+ });
169
+ break;
170
+ }
171
+
172
+ // We can continue to work after recoverable errors
173
+ continue;
174
+ } else {
175
+ this.assignErrorFactoryIfNotSet(() => {
176
+ throw new UnrecoverablePlError(status);
177
+ }, currentHandler.reject);
178
+ currentHandler = undefined;
179
+
180
+ // In case of unrecoverable errors we close the transaction
181
+ break;
182
+ }
183
+ }
184
+
185
+ if (
186
+ currentHandler!.kind !== message.response.oneofKind &&
187
+ message?.multiMessage?.isEmpty !== true
188
+ ) {
189
+ const errorMessage = `inconsistent request response types: ${currentHandler!.kind} !== ${message.response.oneofKind}`;
190
+
191
+ this.assignErrorFactoryIfNotSet(() => {
192
+ throw new Error(errorMessage);
193
+ }, currentHandler.reject);
194
+ currentHandler = undefined;
195
+
196
+ break;
197
+ }
198
+
199
+ if (currentHandler!.expectMultiResponse !== (message.multiMessage !== undefined)) {
200
+ const errorMessage = `inconsistent multi state: ${currentHandler!.expectMultiResponse} !== ${message.multiMessage !== undefined}`;
201
+
202
+ this.assignErrorFactoryIfNotSet(() => {
203
+ throw new Error(errorMessage);
204
+ }, currentHandler.reject);
205
+ currentHandler = undefined;
206
+
207
+ break;
208
+ }
209
+
210
+ // <- at this point we validated everything we can at this level
211
+
212
+ if (message.multiMessage !== undefined) {
213
+ if (!message.multiMessage.isEmpty) {
214
+ if (message.multiMessage.id !== responseAggregator!.length + 1) {
215
+ const errorMessage = `inconsistent multi id: ${message.multiMessage.id} !== ${responseAggregator!.length + 1}`;
216
+
217
+ this.assignErrorFactoryIfNotSet(() => {
218
+ throw new Error(errorMessage);
219
+ }, currentHandler.reject);
220
+ currentHandler = undefined;
221
+
222
+ break;
223
+ }
224
+
225
+ responseAggregator!.push(message.response);
226
+ }
227
+
228
+ if (message.multiMessage.isLast) {
229
+ (currentHandler as AnyMultiResponseHandler).resolve(responseAggregator!);
230
+ responseAggregator = undefined;
231
+ currentHandler = undefined;
232
+ }
233
+ } else {
234
+ (currentHandler as AnySingleResponseHandler).resolve(message.response);
235
+ currentHandler = undefined;
236
+ }
237
+ }
238
+ } catch (e: any) {
239
+ return this.assignErrorFactoryIfNotSet(() => {
240
+ rethrowMeaningfulError(e, true);
241
+ }, currentHandler?.reject);
242
+ } finally {
243
+ await this.close();
244
+ }
245
+ return null;
246
+ }
247
+
248
+ /** Executed after termination of incoming message processor */
249
+ private async close(): Promise<void> {
250
+ if (this.closed) return;
251
+
252
+ this.closed = true;
253
+
254
+ // Rejecting all messages
255
+ let handler: AnyResponseHandler | undefined = undefined;
256
+ while (true) {
257
+ const handler = this.responseHandlerQueue.shift();
258
+ if (!handler) break;
259
+ if (this.errorFactory) handler.reject(new RethrowError(this.errorFactory));
260
+ else handler.reject(new Error('no reply'));
261
+ }
262
+
263
+ // closing outgoing stream
264
+ await this.stream.requests.complete();
265
+ }
266
+
267
+ /** Forcefully close the transaction, terminate all connections and reject all pending requests */
268
+ public abort(cause?: Error) {
269
+ this.assignErrorFactoryIfNotSet(() => {
270
+ throw new Error(`transaction aborted`, { cause });
271
+ });
272
+ this.abortController.abort(cause);
273
+ }
274
+
275
+ /** Await incoming message loop termination and throw any leftover errors if it was unsuccessful */
276
+ public async await(): Promise<void> {
277
+ // for those who want to understand "why?":
278
+ // this way there is no hanging promise that will complete with rejection
279
+ // until await is implicitly requested, the this.incomingProcessorResult
280
+ // always resolves with success
281
+
282
+ const processingResult = await this.incomingProcessorResult;
283
+ if (processingResult !== null) processingResult();
284
+ }
285
+
286
+ public async send<Kind extends ClientMessageRequest['oneofKind']>(
287
+ r: OneOfKind<ClientMessageRequest, Kind>,
288
+ expectMultiResponse: false
289
+ ): Promise<OneOfKind<ServerMessageResponse, Kind>>;
290
+ public async send<Kind extends ClientMessageRequest['oneofKind']>(
291
+ r: OneOfKind<ClientMessageRequest, Kind>,
292
+ expectMultiResponse: true
293
+ ): Promise<OneOfKind<ServerMessageResponse, Kind>[]>;
294
+ /** Generate proper client message and send it to the server, and returns a promise of future response. */
295
+ public async send<Kind extends ClientMessageRequest['oneofKind']>(
296
+ r: OneOfKind<ClientMessageRequest, Kind>,
297
+ expectMultiResponse: boolean
298
+ ): Promise<OneOfKind<ServerMessageResponse, Kind> | OneOfKind<ServerMessageResponse, Kind>[]> {
299
+ if (this.errorFactory) return Promise.reject(new RethrowError(this.errorFactory));
300
+
301
+ if (this.closed) return Promise.reject(new Error('Transaction already closed'));
302
+
303
+ // Note: Promise synchronously executes a callback passed to a constructor
304
+ const result = new Promise<OneOfKind<ServerMessageResponse, Kind>>((resolve, reject) => {
305
+ this.responseHandlerQueue.push(
306
+ createResponseHandler(r.oneofKind, expectMultiResponse, resolve, reject)
307
+ );
308
+ });
309
+
310
+ // Awaiting message dispatch to catch any associated errors.
311
+ // There is no hurry, we are not going to receive a response until message is sent.
312
+ await this.stream.requests.send({
313
+ requestId: this.requestIdxCounter++,
314
+ request: r
315
+ });
316
+
317
+ try {
318
+ return await result;
319
+ } catch (e: any) {
320
+ if (e instanceof RethrowError) e.rethrowLambda();
321
+ throw new Error('Error while waiting for response', { cause: e });
322
+ }
323
+ }
324
+
325
+ private _completed = false;
326
+
327
+ /** Safe to call multiple times */
328
+ public async complete() {
329
+ if (this._completed) return;
330
+ this._completed = true;
331
+ await this.stream.requests.complete();
332
+ }
333
+ }
@@ -0,0 +1,173 @@
1
+ import { withTempRoot } from '../test/test_config';
2
+ import { StructTestResource, ValueTestResource } from '../helpers/pl';
3
+ import { field, toGlobalFieldId, toGlobalResourceId } from './transaction';
4
+ import { RecoverablePlError } from './errors';
5
+ import * as tp from 'node:timers/promises';
6
+
7
+ test('get field', async () => {
8
+ await withTempRoot(async (pl) => {
9
+ const [rr0, theField1] = await pl.withWriteTx('resource1', async (tx) => {
10
+ const r0 = tx.createStruct(StructTestResource);
11
+ const r1 = tx.createStruct(StructTestResource);
12
+ const f0 = { resourceId: tx.clientRoot, fieldName: 'test0' };
13
+ const f1 = { resourceId: tx.clientRoot, fieldName: 'test1' };
14
+
15
+ tx.createField(f0, 'Dynamic');
16
+ tx.createField(f1, 'Dynamic');
17
+ tx.setField(f0, r0);
18
+ tx.setField(f1, r1);
19
+
20
+ const theField1 = { resourceId: r1, fieldName: 'theField' };
21
+ tx.createField(theField1, 'Input');
22
+ tx.lock(r1);
23
+ tx.setField(theField1, tx.getFutureFieldValue(r0, 'theField', 'Input'));
24
+
25
+ await tx.commit();
26
+ return [await r0.globalId, await toGlobalFieldId(theField1)];
27
+ });
28
+
29
+ const theField0 = { resourceId: rr0, fieldName: 'theField' };
30
+
31
+ let fieldState = await pl.withReadTx('test', async (tx) => {
32
+ return await tx.getField(theField1);
33
+ });
34
+ expect(fieldState.status === 'Empty' || fieldState.status === 'Assigned').toBe(true);
35
+
36
+ await pl.withWriteTx('resource1', async (tx) => {
37
+ tx.createField(theField0, 'Input');
38
+ tx.lock(rr0);
39
+ tx.setField(theField0, tx.createValue(ValueTestResource, Buffer.from('hello')));
40
+ await tx.commit();
41
+ return theField0;
42
+ });
43
+
44
+ while (true) {
45
+ fieldState = await pl.withReadTx('test', async (tx) => {
46
+ return await tx.getField(theField1);
47
+ });
48
+ if (fieldState.status === 'Resolved') break;
49
+ await tp.setTimeout(10);
50
+ }
51
+ });
52
+ });
53
+
54
+ test('handle absent resource error', async () => {
55
+ await withTempRoot(async (pl) => {
56
+ const [rr0, ff0] = await pl.withWriteTx('testCreateResource', async (tx) => {
57
+ const r0 = tx.createStruct(StructTestResource);
58
+ const f0 = { resourceId: tx.clientRoot, fieldName: 'test0' };
59
+
60
+ tx.createField(f0, 'Dynamic');
61
+ tx.setField(f0, r0);
62
+
63
+ await tx.commit();
64
+ return [await r0.globalId, await toGlobalFieldId(f0)];
65
+ });
66
+
67
+ await pl.withWriteTx(
68
+ 'testDeleteResource',
69
+ async (tx) => {
70
+ await tx.getResourceData(rr0, true);
71
+ tx.removeField(ff0);
72
+ await tx.commit();
73
+ },
74
+ { sync: true }
75
+ );
76
+
77
+ let rState = await pl.withReadTx('testRetrieveResource', async (tx) => {
78
+ await expect(async () => {
79
+ await tx.getResourceData(rr0, true);
80
+ }).rejects.toThrow(RecoverablePlError);
81
+ return await tx.getResourceData(tx.clientRoot, true);
82
+ });
83
+
84
+ expect(rState.fields).toHaveLength(0);
85
+
86
+ rState = await pl.withReadTx('testRetrieveResource', async (tx) => {
87
+ await expect(async () => {
88
+ await tx.listKeyValues(rr0);
89
+ }).rejects.toThrow(RecoverablePlError);
90
+ return await tx.getResourceData(tx.clientRoot, true);
91
+ });
92
+
93
+ expect(rState.fields).toHaveLength(0);
94
+
95
+ rState = await pl.withReadTx('testRetrieveResource', async (tx) => {
96
+ await expect(async () => {
97
+ await tx.getField(ff0);
98
+ }).rejects.toThrow(RecoverablePlError);
99
+ return await tx.getResourceData(tx.clientRoot, true);
100
+ });
101
+
102
+ expect(rState.fields).toHaveLength(0);
103
+
104
+ await pl.withReadTx('testFieldAbsent', async (tx) => {
105
+ expect(await tx.getFieldIfExists(ff0)).toBeUndefined();
106
+ });
107
+ });
108
+ });
109
+
110
+ test('handle KV storage', async () => {
111
+ await withTempRoot(async (pl) => {
112
+ await pl.withWriteTx('writeKV', async (tx) => {
113
+ tx.setKValue(tx.clientRoot, 'a', 'a');
114
+ tx.setKValue(tx.clientRoot, 'b', 'b');
115
+ await tx.commit();
116
+ });
117
+
118
+ await pl.withReadTx('testReadIndividual', async (tx) => {
119
+ expect(await tx.getKValueString(tx.clientRoot, 'a')).toEqual('a');
120
+ expect(await tx.getKValueString(tx.clientRoot, 'b')).toEqual('b');
121
+ });
122
+
123
+ await pl.withReadTx('testReadIndividualAndList', async (tx) => {
124
+ expect(await tx.getKValueString(tx.clientRoot, 'a')).toEqual('a');
125
+ expect(await tx.getKValueString(tx.clientRoot, 'b')).toEqual('b');
126
+ expect(await tx.listKeyValuesString(tx.clientRoot)).toEqual([
127
+ { key: 'a', value: 'a' },
128
+ { key: 'b', value: 'b' }
129
+ ]);
130
+ expect(await tx.getKValueString(tx.clientRoot, 'a')).toEqual('a');
131
+ expect(await tx.getKValueString(tx.clientRoot, 'b')).toEqual('b');
132
+ });
133
+
134
+ await pl.withWriteTx('deleteKV', async (tx) => {
135
+ tx.deleteKValue(tx.clientRoot, 'a');
136
+ await tx.commit();
137
+ });
138
+
139
+ await pl.withReadTx('testReadIndividualAndList2', async (tx) => {
140
+ expect(await tx.getKValueString(tx.clientRoot, 'b')).toEqual('b');
141
+ expect(await tx.listKeyValuesString(tx.clientRoot)).toEqual([{ key: 'b', value: 'b' }]);
142
+ expect(await tx.getKValueString(tx.clientRoot, 'b')).toEqual('b');
143
+ });
144
+ });
145
+ });
146
+
147
+ test('handle empty KV storage', async () => {
148
+ await withTempRoot(async (pl) => {
149
+ await pl.withReadTx('testReadIndividualAndList', async (tx) => {
150
+ expect(await tx.listKeyValuesString(tx.clientRoot)).toEqual([]);
151
+ });
152
+ });
153
+ });
154
+
155
+ test('handle KV storage 2', async () => {
156
+ await withTempRoot(async (pl) => {
157
+ const r1 = await pl.withWriteTx('writeKV', async (tx) => {
158
+ const rr1 = tx.createEphemeral(StructTestResource);
159
+ tx.createField(field(rr1, 'a'), 'Dynamic', rr1);
160
+ tx.setKValue(rr1, 'a', 'a');
161
+ tx.setKValue(rr1, 'b', 'b');
162
+ await tx.commit();
163
+ return await toGlobalResourceId(rr1);
164
+ });
165
+
166
+ await pl.withReadTx('testReadIndividualAndList', async (tx) => {
167
+ expect(await tx.listKeyValuesString(r1)).toEqual([
168
+ { key: 'a', value: 'a' },
169
+ { key: 'b', value: 'b' }
170
+ ]);
171
+ });
172
+ });
173
+ });