@opengeni/storage 0.2.124 → 0.2.126-canary.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/dist/index.d.ts +14 -0
- package/dist/index.js +339 -1
- package/dist/index.js.map +1 -1
- package/dist/workspace-archive-spool.d.ts +28 -0
- package/package.json +3 -3
- package/src/index.ts +103 -0
- package/src/workspace-archive-spool.ts +308 -0
package/src/index.ts
CHANGED
|
@@ -44,6 +44,12 @@ export type ObjectHead = {
|
|
|
44
44
|
VersionToken?: string;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
export {
|
|
48
|
+
uploadWorkspaceArchiveSpool,
|
|
49
|
+
downloadWorkspaceArchiveSpool,
|
|
50
|
+
WorkspaceArchiveStorageError,
|
|
51
|
+
} from "./workspace-archive-spool";
|
|
52
|
+
|
|
47
53
|
export type ObjectStorage = {
|
|
48
54
|
bucket: string;
|
|
49
55
|
backend: "s3-compatible" | "aws-s3" | "azure-blob" | "gcs";
|
|
@@ -102,6 +108,19 @@ export type ObjectStorage = {
|
|
|
102
108
|
body: Uint8Array;
|
|
103
109
|
sha256?: string | null;
|
|
104
110
|
}) => Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* Unconditional authenticated upload from a bounded byte stream. May overwrite
|
|
113
|
+
* an existing key; this is NOT an atomic create-only operation. Callers needing
|
|
114
|
+
* write isolation must supply a fresh unique key and verify stored content.
|
|
115
|
+
*/
|
|
116
|
+
putObjectStream?: (args: {
|
|
117
|
+
key: string;
|
|
118
|
+
contentType: string;
|
|
119
|
+
chunks: AsyncIterable<Uint8Array>;
|
|
120
|
+
byteSize: number;
|
|
121
|
+
sha256?: string;
|
|
122
|
+
signal?: AbortSignal;
|
|
123
|
+
}) => Promise<void>;
|
|
105
124
|
/** Atomic create-only raw PUT. Returns false when the key already exists. */
|
|
106
125
|
putObjectIfAbsent?: (args: {
|
|
107
126
|
key: string;
|
|
@@ -254,6 +273,52 @@ function createS3CompatibleObjectStorage(settings: Settings): ObjectStorage | nu
|
|
|
254
273
|
throw error;
|
|
255
274
|
}
|
|
256
275
|
},
|
|
276
|
+
async putObjectStream(args) {
|
|
277
|
+
const body = Readable.from(args.chunks, {
|
|
278
|
+
objectMode: false,
|
|
279
|
+
highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
|
|
280
|
+
});
|
|
281
|
+
// A producer error can leave the HTTP request waiting for its advertised
|
|
282
|
+
// ContentLength. Abort this request explicitly; never abort the caller's
|
|
283
|
+
// controller or rely on provider timeouts to settle the failed upload.
|
|
284
|
+
const request = new AbortController();
|
|
285
|
+
let producerFailed = false;
|
|
286
|
+
let producerError: unknown;
|
|
287
|
+
const onBodyError = (error: unknown) => {
|
|
288
|
+
producerFailed = true;
|
|
289
|
+
producerError = error;
|
|
290
|
+
request.abort(error);
|
|
291
|
+
};
|
|
292
|
+
const onCallerAbort = () => request.abort(args.signal?.reason);
|
|
293
|
+
body.once("error", onBodyError);
|
|
294
|
+
// Keep an error listener through asynchronous destruction, then release it.
|
|
295
|
+
const detachBodyError = () => body.off("error", onBodyError);
|
|
296
|
+
body.once("close", detachBodyError);
|
|
297
|
+
args.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
298
|
+
if (args.signal?.aborted) onCallerAbort();
|
|
299
|
+
try {
|
|
300
|
+
await requestClient.send(
|
|
301
|
+
new PutObjectCommand({
|
|
302
|
+
Bucket: settings.objectStorageBucket,
|
|
303
|
+
Key: args.key,
|
|
304
|
+
ContentType: args.contentType,
|
|
305
|
+
ContentLength: args.byteSize,
|
|
306
|
+
Body: body,
|
|
307
|
+
Metadata: args.sha256 ? { sha256: args.sha256 } : undefined,
|
|
308
|
+
}),
|
|
309
|
+
{ abortSignal: request.signal },
|
|
310
|
+
);
|
|
311
|
+
if (producerFailed) throw producerError;
|
|
312
|
+
} catch (error) {
|
|
313
|
+
// The SDK commonly rejects with AbortError after our producer abort;
|
|
314
|
+
// preserve the original integrity/source failure for its caller.
|
|
315
|
+
if (producerFailed) throw producerError;
|
|
316
|
+
throw error;
|
|
317
|
+
} finally {
|
|
318
|
+
args.signal?.removeEventListener("abort", onCallerAbort);
|
|
319
|
+
body.destroy();
|
|
320
|
+
}
|
|
321
|
+
},
|
|
257
322
|
async putObjectStreamIfAbsent(args) {
|
|
258
323
|
try {
|
|
259
324
|
await requestClient.send(
|
|
@@ -516,6 +581,23 @@ function createGcsObjectStorage(settings: Settings): ObjectStorage {
|
|
|
516
581
|
throw error;
|
|
517
582
|
}
|
|
518
583
|
},
|
|
584
|
+
async putObjectStream(args) {
|
|
585
|
+
const destination = bucket.file(args.key).createWriteStream({
|
|
586
|
+
resumable: false,
|
|
587
|
+
contentType: args.contentType,
|
|
588
|
+
highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
|
|
589
|
+
...(args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}),
|
|
590
|
+
});
|
|
591
|
+
const source = Readable.from(args.chunks, {
|
|
592
|
+
objectMode: false,
|
|
593
|
+
highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
|
|
594
|
+
});
|
|
595
|
+
if (args.signal) {
|
|
596
|
+
await pipeline(source, destination, { signal: args.signal });
|
|
597
|
+
} else {
|
|
598
|
+
await pipeline(source, destination);
|
|
599
|
+
}
|
|
600
|
+
},
|
|
519
601
|
async putObjectStreamIfAbsent(args) {
|
|
520
602
|
const destination = bucket.file(args.key).createWriteStream({
|
|
521
603
|
resumable: false,
|
|
@@ -709,6 +791,27 @@ function createAzureBlobObjectStorage(settings: Settings): ObjectStorage | null
|
|
|
709
791
|
throw error;
|
|
710
792
|
}
|
|
711
793
|
},
|
|
794
|
+
async putObjectStream(args) {
|
|
795
|
+
const blobClient = requestContainerClient.getBlockBlobClient(args.key);
|
|
796
|
+
const source = Readable.from(args.chunks, {
|
|
797
|
+
objectMode: false,
|
|
798
|
+
highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
|
|
799
|
+
});
|
|
800
|
+
try {
|
|
801
|
+
await blobClient.uploadStream(
|
|
802
|
+
source,
|
|
803
|
+
INTERNAL_STREAM_BUFFER_BYTES,
|
|
804
|
+
INTERNAL_STREAM_CONCURRENCY,
|
|
805
|
+
{
|
|
806
|
+
blobHTTPHeaders: { blobContentType: args.contentType },
|
|
807
|
+
...(args.sha256 ? { metadata: { sha256: args.sha256 } } : {}),
|
|
808
|
+
...(args.signal ? { abortSignal: args.signal } : {}),
|
|
809
|
+
},
|
|
810
|
+
);
|
|
811
|
+
} finally {
|
|
812
|
+
source.destroy();
|
|
813
|
+
}
|
|
814
|
+
},
|
|
712
815
|
async putObjectStreamIfAbsent(args) {
|
|
713
816
|
const blobClient = requestContainerClient.getBlockBlobClient(args.key);
|
|
714
817
|
try {
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { chmod, mkdtemp, open, rm, type FileHandle } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { ObjectStorage } from "./index";
|
|
7
|
+
import { DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES } from "./bounded-object-read";
|
|
8
|
+
|
|
9
|
+
/** Structural runtime-compatible contract; storage must not depend on runtime. */
|
|
10
|
+
export type WorkspaceArchiveSpool = {
|
|
11
|
+
path: string;
|
|
12
|
+
byteSize: number;
|
|
13
|
+
sha256: string;
|
|
14
|
+
open: () => AsyncIterable<Uint8Array>;
|
|
15
|
+
dispose: () => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Transfer granularity, not an archive size limit.
|
|
19
|
+
const CHUNK_BYTES = DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES;
|
|
20
|
+
type ExpectedArchive = { bytes: number; sha256: string };
|
|
21
|
+
|
|
22
|
+
/** Restore classification shared with runtime without importing runtime. */
|
|
23
|
+
export class WorkspaceArchiveStorageError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
readonly code: "archive_base64_invalid" | "archive_hash_mismatch" | "archive_hydration_failed",
|
|
26
|
+
message: string,
|
|
27
|
+
readonly retryable: boolean,
|
|
28
|
+
options?: ErrorOptions,
|
|
29
|
+
) {
|
|
30
|
+
super(message, options);
|
|
31
|
+
this.name = "WorkspaceArchiveStorageError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Uploads at a caller-owned fresh unique key and independently verifies readback.
|
|
37
|
+
* Unconditional PUT may overwrite: callers must never reuse published keys.
|
|
38
|
+
* Never owns/disposes the input spool or deletes a failed/unpublished object.
|
|
39
|
+
*/
|
|
40
|
+
export async function uploadWorkspaceArchiveSpool(
|
|
41
|
+
storage: ObjectStorage,
|
|
42
|
+
key: string,
|
|
43
|
+
spool: WorkspaceArchiveSpool,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
requireBoundedReads(storage);
|
|
46
|
+
if (!storage.putObjectStream) {
|
|
47
|
+
throw new WorkspaceArchiveStorageError(
|
|
48
|
+
"archive_hydration_failed",
|
|
49
|
+
"Workspace archive storage unsupported: unconditional streaming upload required",
|
|
50
|
+
false,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const expected = { bytes: spool.byteSize, sha256: spool.sha256 };
|
|
54
|
+
validateExpected(expected);
|
|
55
|
+
let validated = false;
|
|
56
|
+
let streamFailed = false;
|
|
57
|
+
let streamFailure: unknown;
|
|
58
|
+
const chunks = (async function* () {
|
|
59
|
+
const digest = createHash("sha256");
|
|
60
|
+
let bytes = 0;
|
|
61
|
+
try {
|
|
62
|
+
for await (const chunk of spool.open()) {
|
|
63
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength > expected.bytes - bytes) {
|
|
64
|
+
throw new WorkspaceArchiveStorageError(
|
|
65
|
+
"archive_hash_mismatch",
|
|
66
|
+
"Workspace archive upload stream has invalid size",
|
|
67
|
+
false,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
bytes += chunk.byteLength;
|
|
71
|
+
digest.update(chunk);
|
|
72
|
+
yield chunk;
|
|
73
|
+
}
|
|
74
|
+
if (bytes !== expected.bytes || digest.digest("hex") !== expected.sha256) {
|
|
75
|
+
throw new WorkspaceArchiveStorageError(
|
|
76
|
+
"archive_hash_mismatch",
|
|
77
|
+
"Workspace archive upload stream digest or size mismatch",
|
|
78
|
+
false,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
validated = true;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
streamFailed = true;
|
|
84
|
+
streamFailure = error;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
try {
|
|
89
|
+
await storage.putObjectStream({
|
|
90
|
+
key,
|
|
91
|
+
contentType: "application/x-tar",
|
|
92
|
+
chunks,
|
|
93
|
+
byteSize: expected.bytes,
|
|
94
|
+
sha256: expected.sha256,
|
|
95
|
+
});
|
|
96
|
+
if (!validated) {
|
|
97
|
+
if (streamFailed) throw streamFailure;
|
|
98
|
+
throw new WorkspaceArchiveStorageError(
|
|
99
|
+
"archive_hydration_failed",
|
|
100
|
+
"Workspace archive upload provider did not completely consume and validate the stream",
|
|
101
|
+
false,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
// A successful PUT and SHA metadata are not proof of stored content.
|
|
105
|
+
await verifyRanges(storage, key, expected);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const failure = streamFailed ? streamFailure : error;
|
|
108
|
+
if (failure instanceof WorkspaceArchiveStorageError) throw failure;
|
|
109
|
+
throw new WorkspaceArchiveStorageError(
|
|
110
|
+
"archive_hydration_failed",
|
|
111
|
+
"Workspace archive upload or readback failed",
|
|
112
|
+
true,
|
|
113
|
+
{ cause: failure },
|
|
114
|
+
);
|
|
115
|
+
} finally {
|
|
116
|
+
// Close an early-terminated provider's iterator without owning the spool.
|
|
117
|
+
await chunks.return(undefined);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Caller owns the returned private spool and must dispose it after use. */
|
|
122
|
+
export async function downloadWorkspaceArchiveSpool(
|
|
123
|
+
storage: ObjectStorage,
|
|
124
|
+
key: string,
|
|
125
|
+
inputExpected: ExpectedArchive,
|
|
126
|
+
): Promise<WorkspaceArchiveSpool> {
|
|
127
|
+
const expected = { bytes: inputExpected.bytes, sha256: inputExpected.sha256 };
|
|
128
|
+
requireBoundedReads(storage);
|
|
129
|
+
validateExpected(expected);
|
|
130
|
+
const directory = await mkdtemp(join(tmpdir(), "opengeni-workspace-archive-"));
|
|
131
|
+
const path = join(directory, "archive.tar");
|
|
132
|
+
let handle: FileHandle | undefined;
|
|
133
|
+
try {
|
|
134
|
+
await chmod(directory, 0o700);
|
|
135
|
+
handle = await open(path, "wx", 0o600);
|
|
136
|
+
await verifyRanges(storage, key, expected, async (chunk) => {
|
|
137
|
+
let offset = 0;
|
|
138
|
+
while (offset < chunk.byteLength) {
|
|
139
|
+
const { bytesWritten } = await handle!.write(chunk, offset, chunk.byteLength - offset);
|
|
140
|
+
if (bytesWritten <= 0)
|
|
141
|
+
throw new WorkspaceArchiveStorageError(
|
|
142
|
+
"archive_hydration_failed",
|
|
143
|
+
"Workspace archive spool write made no progress",
|
|
144
|
+
true,
|
|
145
|
+
);
|
|
146
|
+
offset += bytesWritten;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
await handle.close();
|
|
150
|
+
handle = undefined;
|
|
151
|
+
let disposed = false;
|
|
152
|
+
return {
|
|
153
|
+
path,
|
|
154
|
+
byteSize: expected.bytes,
|
|
155
|
+
sha256: expected.sha256,
|
|
156
|
+
async *open() {
|
|
157
|
+
if (disposed) throw new Error("Workspace archive spool is disposed");
|
|
158
|
+
const stream = createReadStream(path, { highWaterMark: CHUNK_BYTES });
|
|
159
|
+
try {
|
|
160
|
+
for await (const chunk of stream) yield chunk as Uint8Array;
|
|
161
|
+
} finally {
|
|
162
|
+
stream.destroy();
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
async dispose() {
|
|
166
|
+
disposed = true;
|
|
167
|
+
await rm(directory, { recursive: true, force: true });
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
} catch (error) {
|
|
171
|
+
await handle?.close().catch(() => undefined);
|
|
172
|
+
await rm(directory, { recursive: true, force: true });
|
|
173
|
+
if (error instanceof WorkspaceArchiveStorageError) throw error;
|
|
174
|
+
throw new WorkspaceArchiveStorageError(
|
|
175
|
+
"archive_hydration_failed",
|
|
176
|
+
"Workspace archive download failed",
|
|
177
|
+
true,
|
|
178
|
+
{ cause: error },
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function requireBoundedReads(storage: ObjectStorage): void {
|
|
184
|
+
if (!storage.headObject || !storage.getObjectRange) {
|
|
185
|
+
throw new WorkspaceArchiveStorageError(
|
|
186
|
+
"archive_hydration_failed",
|
|
187
|
+
"Workspace archive storage unsupported: versioned head/range reads required",
|
|
188
|
+
false,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateExpected(expected: ExpectedArchive): void {
|
|
194
|
+
if (
|
|
195
|
+
!Number.isSafeInteger(expected.bytes) ||
|
|
196
|
+
expected.bytes < 0 ||
|
|
197
|
+
!/^[0-9a-f]{64}$/u.test(expected.sha256)
|
|
198
|
+
) {
|
|
199
|
+
throw new WorkspaceArchiveStorageError(
|
|
200
|
+
"archive_hydration_failed",
|
|
201
|
+
"Workspace archive expected size or SHA-256 is invalid",
|
|
202
|
+
false,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function verifyRanges(
|
|
208
|
+
storage: ObjectStorage,
|
|
209
|
+
key: string,
|
|
210
|
+
expected: ExpectedArchive,
|
|
211
|
+
consume?: (bytes: Uint8Array) => Promise<void>,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const head = await storage.headObject!(key);
|
|
214
|
+
if (!head)
|
|
215
|
+
throw new WorkspaceArchiveStorageError(
|
|
216
|
+
"archive_base64_invalid",
|
|
217
|
+
"Workspace archive object is missing",
|
|
218
|
+
false,
|
|
219
|
+
);
|
|
220
|
+
if (head.ContentLength !== expected.bytes)
|
|
221
|
+
throw new WorkspaceArchiveStorageError(
|
|
222
|
+
"archive_hash_mismatch",
|
|
223
|
+
"Workspace archive object size mismatch",
|
|
224
|
+
false,
|
|
225
|
+
);
|
|
226
|
+
const version = head.VersionToken;
|
|
227
|
+
if (typeof version !== "string" || version.length === 0 || version.length > 2048) {
|
|
228
|
+
throw new WorkspaceArchiveStorageError(
|
|
229
|
+
"archive_hydration_failed",
|
|
230
|
+
"Workspace archive storage unsupported: valid object version token required",
|
|
231
|
+
false,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const digest = createHash("sha256");
|
|
235
|
+
let bytes = 0;
|
|
236
|
+
while (bytes < expected.bytes) {
|
|
237
|
+
const length = Math.min(CHUNK_BYTES, expected.bytes - bytes);
|
|
238
|
+
const result = await storage.getObjectRange!({
|
|
239
|
+
key,
|
|
240
|
+
start: bytes,
|
|
241
|
+
endInclusive: bytes + length - 1,
|
|
242
|
+
expectedVersionToken: version,
|
|
243
|
+
});
|
|
244
|
+
if (!result) {
|
|
245
|
+
// Adapters also return null for failed If-Match/generation reads. Do not
|
|
246
|
+
// classify replacement as permanent loss or retry without a version pin.
|
|
247
|
+
const current = await storage.headObject!(key);
|
|
248
|
+
if (!current) {
|
|
249
|
+
throw new WorkspaceArchiveStorageError(
|
|
250
|
+
"archive_base64_invalid",
|
|
251
|
+
"Workspace archive object is missing during range read",
|
|
252
|
+
false,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
throw new WorkspaceArchiveStorageError(
|
|
256
|
+
"archive_hydration_failed",
|
|
257
|
+
current.VersionToken !== version
|
|
258
|
+
? "Workspace archive object version changed"
|
|
259
|
+
: "Workspace archive pinned range is temporarily unavailable",
|
|
260
|
+
true,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (result.versionToken !== version)
|
|
264
|
+
throw new WorkspaceArchiveStorageError(
|
|
265
|
+
"archive_hydration_failed",
|
|
266
|
+
"Workspace archive object version changed",
|
|
267
|
+
true,
|
|
268
|
+
);
|
|
269
|
+
if (!(result.bytes instanceof Uint8Array) || result.bytes.byteLength !== length) {
|
|
270
|
+
throw new WorkspaceArchiveStorageError(
|
|
271
|
+
"archive_hash_mismatch",
|
|
272
|
+
"Workspace archive object range is truncated or has invalid size",
|
|
273
|
+
false,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
digest.update(result.bytes);
|
|
277
|
+
await consume?.(result.bytes);
|
|
278
|
+
bytes += result.bytes.byteLength;
|
|
279
|
+
}
|
|
280
|
+
const finalHead = await storage.headObject!(key);
|
|
281
|
+
if (!finalHead)
|
|
282
|
+
throw new WorkspaceArchiveStorageError(
|
|
283
|
+
"archive_base64_invalid",
|
|
284
|
+
"Workspace archive object is missing after range read",
|
|
285
|
+
false,
|
|
286
|
+
);
|
|
287
|
+
if (finalHead.VersionToken !== version) {
|
|
288
|
+
throw new WorkspaceArchiveStorageError(
|
|
289
|
+
"archive_hydration_failed",
|
|
290
|
+
"Workspace archive object version changed",
|
|
291
|
+
true,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (finalHead.ContentLength !== expected.bytes) {
|
|
295
|
+
throw new WorkspaceArchiveStorageError(
|
|
296
|
+
"archive_hash_mismatch",
|
|
297
|
+
"Workspace archive object size changed",
|
|
298
|
+
false,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (bytes !== expected.bytes || digest.digest("hex") !== expected.sha256) {
|
|
302
|
+
throw new WorkspaceArchiveStorageError(
|
|
303
|
+
"archive_hash_mismatch",
|
|
304
|
+
"Workspace archive object digest or size mismatch",
|
|
305
|
+
false,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
}
|