@hot-updater/cloudflare 0.36.7 → 1.0.0-rc.1
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/iac/index.cjs +988 -10
- package/dist/iac/index.mjs +991 -14
- package/dist/index.cjs +919 -6989
- package/dist/index.d.cts +11 -47
- package/dist/index.d.mts +11 -47
- package/dist/index.mjs +917 -7006
- package/dist/worker/index.cjs +898 -336
- package/dist/worker/index.d.cts +18 -33
- package/dist/worker/index.d.mts +18 -33
- package/dist/worker/index.mjs +899 -331
- package/package.json +7 -10
- package/sql/bundles.sql +183 -35
- package/src/d1Database.spec.ts +143 -392
- package/src/d1Database.ts +55 -650
- package/src/d1Implementation.spec.ts +252 -0
- package/src/d1Implementation.ts +742 -0
- package/src/d1Query.spec.ts +46 -0
- package/src/d1Query.ts +89 -0
- package/src/d1Rows.spec.ts +111 -0
- package/src/d1Rows.ts +326 -0
- package/src/d1Sql.spec.ts +30 -0
- package/src/d1Sql.ts +175 -0
- package/src/r2S3Storage.ts +93 -137
- package/src/r2Storage.spec.ts +120 -218
- package/src/r2Storage.ts +13 -61
- package/src/r2WorkerStorage.spec.ts +161 -50
- package/src/r2WorkerStorage.ts +85 -118
- package/src/worker/d1Database.spec.ts +33 -0
- package/src/worker/d1Database.ts +52 -0
- package/src/worker/index.ts +3 -31
- package/worker/dist/README.md +1 -1
- package/worker/dist/index.js +5968 -4701
- package/worker/dist/index.js.map +4 -4
- package/worker/migrations/0001_hot-updater_1.0.0.sql +205 -0
- package/worker/src/databaseMigration.integration.spec.ts +130 -0
- package/worker/src/databasePlugins.integration.spec.ts +292 -0
- package/worker/src/index.integration.spec.ts +102 -294
- package/worker/src/index.ts +14 -66
- package/worker/src/storagePlugin.integration.spec.ts +59 -0
- package/worker/wrangler.json +5 -2
- package/src/cloudflareWorkerDatabase.spec.ts +0 -260
- package/src/cloudflareWorkerDatabase.ts +0 -651
- package/src/r2WranglerStorage.ts +0 -193
- package/worker/migrations/0001_hot-updater_init.sql +0 -16
- package/worker/migrations/0002_hot-updater_0.13.0.sql +0 -8
- package/worker/migrations/0003_hot-updater_0.18.0.sql +0 -38
- package/worker/migrations/0004_hot-updater_0.29.0.sql +0 -11
- package/worker/migrations/0005_hot-updater_0.31.0.sql +0 -24
package/src/d1Sql.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DatabaseStringComparisonMode,
|
|
3
|
+
DatabaseWhereConnector,
|
|
4
|
+
DatabaseWhereOperator,
|
|
5
|
+
} from "@hot-updater/plugin-core/internal";
|
|
6
|
+
|
|
7
|
+
export type D1Query = {
|
|
8
|
+
readonly sql: string;
|
|
9
|
+
readonly params: readonly string[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type D1Predicate = {
|
|
13
|
+
readonly field: string;
|
|
14
|
+
readonly operator?: DatabaseWhereOperator;
|
|
15
|
+
readonly value: unknown;
|
|
16
|
+
readonly connector?: DatabaseWhereConnector;
|
|
17
|
+
readonly mode?: DatabaseStringComparisonMode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type D1Sort = {
|
|
21
|
+
readonly field: string;
|
|
22
|
+
readonly direction: "asc" | "desc";
|
|
23
|
+
readonly nulls?: "first" | "last";
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
class InvalidD1PredicateError extends Error {
|
|
27
|
+
readonly name = "InvalidD1PredicateError";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const encodeD1Value = (value: unknown): string => {
|
|
31
|
+
const encoded = JSON.stringify(value);
|
|
32
|
+
if (encoded === undefined) {
|
|
33
|
+
throw new TypeError("D1 values must be JSON-serializable.");
|
|
34
|
+
}
|
|
35
|
+
return encoded;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const bind = (value: unknown): D1Query => ({
|
|
39
|
+
sql: "json_extract(?, '$')",
|
|
40
|
+
params: [encodeD1Value(value)],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const escapeLikePattern = (value: string): string =>
|
|
44
|
+
value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
|
|
45
|
+
|
|
46
|
+
const stringPredicate = (
|
|
47
|
+
condition: D1Predicate,
|
|
48
|
+
operator: "contains" | "ends_with" | "starts_with",
|
|
49
|
+
): D1Query => {
|
|
50
|
+
if (typeof condition.value !== "string") {
|
|
51
|
+
throw new InvalidD1PredicateError();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (condition.mode !== "insensitive") {
|
|
55
|
+
const parameter = bind(condition.value);
|
|
56
|
+
const column = `${condition.field} COLLATE BINARY`;
|
|
57
|
+
const value = `${parameter.sql} COLLATE BINARY`;
|
|
58
|
+
if (operator === "contains" || operator === "starts_with") {
|
|
59
|
+
return {
|
|
60
|
+
sql: `instr(${column}, ${value}) ${operator === "contains" ? "> 0" : "= 1"}`,
|
|
61
|
+
params: parameter.params,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
sql: `substr(${column}, length(${condition.field}) - length(${parameter.sql}) + 1) = ${value}`,
|
|
66
|
+
params: [...parameter.params, ...parameter.params],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const literal = escapeLikePattern(condition.value);
|
|
71
|
+
const pattern =
|
|
72
|
+
operator === "contains"
|
|
73
|
+
? `%${literal}%`
|
|
74
|
+
: operator === "starts_with"
|
|
75
|
+
? `${literal}%`
|
|
76
|
+
: `%${literal}`;
|
|
77
|
+
const parameter = bind(pattern);
|
|
78
|
+
return {
|
|
79
|
+
sql: `lower(${condition.field}) LIKE lower(${parameter.sql}) ESCAPE '\\'`,
|
|
80
|
+
params: parameter.params,
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const predicate = (condition: D1Predicate): D1Query => {
|
|
85
|
+
const operator = condition.operator ?? "eq";
|
|
86
|
+
switch (operator) {
|
|
87
|
+
case "eq":
|
|
88
|
+
case "ne": {
|
|
89
|
+
if (condition.value === null) {
|
|
90
|
+
return {
|
|
91
|
+
sql: `${condition.field} IS ${operator === "ne" ? "NOT " : ""}NULL`,
|
|
92
|
+
params: [],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const parameter = bind(condition.value);
|
|
96
|
+
const insensitive =
|
|
97
|
+
"mode" in condition && condition.mode === "insensitive";
|
|
98
|
+
const column = insensitive
|
|
99
|
+
? `lower(${condition.field})`
|
|
100
|
+
: condition.field;
|
|
101
|
+
const value = insensitive ? `lower(${parameter.sql})` : parameter.sql;
|
|
102
|
+
return {
|
|
103
|
+
sql: `${column} ${operator === "eq" ? "=" : "<>"} ${value}`,
|
|
104
|
+
params: parameter.params,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
case "gt":
|
|
108
|
+
case "gte":
|
|
109
|
+
case "lt":
|
|
110
|
+
case "lte": {
|
|
111
|
+
const sqlOperator = {
|
|
112
|
+
gt: ">",
|
|
113
|
+
gte: ">=",
|
|
114
|
+
lt: "<",
|
|
115
|
+
lte: "<=",
|
|
116
|
+
}[operator];
|
|
117
|
+
const parameter = bind(condition.value);
|
|
118
|
+
return {
|
|
119
|
+
sql: `${condition.field} ${sqlOperator} ${parameter.sql}`,
|
|
120
|
+
params: parameter.params,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
case "in":
|
|
124
|
+
case "not_in": {
|
|
125
|
+
if (!Array.isArray(condition.value)) {
|
|
126
|
+
throw new InvalidD1PredicateError();
|
|
127
|
+
}
|
|
128
|
+
if (condition.value.length === 0) {
|
|
129
|
+
return { sql: operator === "not_in" ? "1 = 1" : "1 = 0", params: [] };
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
sql: `${condition.field} ${operator === "not_in" ? "NOT " : ""}IN (SELECT value FROM json_each(?))`,
|
|
133
|
+
params: [encodeD1Value(condition.value)],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
case "contains":
|
|
137
|
+
case "starts_with":
|
|
138
|
+
case "ends_with":
|
|
139
|
+
return stringPredicate(condition, operator);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const buildD1Where = (
|
|
144
|
+
where: readonly D1Predicate[] | undefined,
|
|
145
|
+
): D1Query => {
|
|
146
|
+
const [first, ...rest] = where ?? [];
|
|
147
|
+
if (first === undefined) return { sql: "", params: [] };
|
|
148
|
+
const initial = predicate(first);
|
|
149
|
+
let sql = initial.sql;
|
|
150
|
+
const params = [...initial.params];
|
|
151
|
+
for (const condition of rest) {
|
|
152
|
+
const next = predicate(condition);
|
|
153
|
+
sql = `(${sql} ${condition.connector === "OR" ? "OR" : "AND"} ${next.sql})`;
|
|
154
|
+
params.push(...next.params);
|
|
155
|
+
}
|
|
156
|
+
return { sql: ` WHERE ${sql}`, params };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const buildD1Order = (orderBy: readonly D1Sort[] | undefined): string =>
|
|
160
|
+
orderBy === undefined
|
|
161
|
+
? ""
|
|
162
|
+
: ` ORDER BY ${orderBy
|
|
163
|
+
.map(
|
|
164
|
+
(clause) =>
|
|
165
|
+
`${clause.field} ${clause.direction.toUpperCase()}${
|
|
166
|
+
clause.nulls ? ` NULLS ${clause.nulls.toUpperCase()}` : ""
|
|
167
|
+
}`,
|
|
168
|
+
)
|
|
169
|
+
.join(", ")}`;
|
|
170
|
+
|
|
171
|
+
export const d1Placeholders = (count: number): string =>
|
|
172
|
+
Array.from({ length: count }, () => "json_extract(?, '$')").join(", ");
|
|
173
|
+
|
|
174
|
+
export const encodeD1Values = (values: readonly unknown[]): readonly string[] =>
|
|
175
|
+
values.map(encodeD1Value);
|
package/src/r2S3Storage.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
1
|
import {
|
|
5
2
|
DeleteObjectCommand,
|
|
6
3
|
GetObjectCommand,
|
|
@@ -9,189 +6,148 @@ import {
|
|
|
9
6
|
type S3ClientConfig,
|
|
10
7
|
} from "@aws-sdk/client-s3";
|
|
11
8
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
12
|
-
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
13
9
|
import {
|
|
14
10
|
createStorageKeyBuilder,
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
createStorageDownloadUrl,
|
|
12
|
+
createStoragePlugin,
|
|
13
|
+
createStorageUri,
|
|
17
14
|
parseStorageUri,
|
|
18
|
-
type
|
|
15
|
+
type StoragePluginWith,
|
|
19
16
|
} from "@hot-updater/plugin-core";
|
|
20
17
|
|
|
21
18
|
export interface R2S3StorageConfig extends S3ClientConfig {
|
|
22
19
|
accountId: string;
|
|
23
20
|
bucketName: string;
|
|
24
21
|
credentials: NonNullable<S3ClientConfig["credentials"]>;
|
|
25
|
-
/**
|
|
26
|
-
* Base path where bundles will be stored in the bucket
|
|
27
|
-
*/
|
|
22
|
+
/** Base path where bundles will be stored in the bucket. */
|
|
28
23
|
basePath?: string;
|
|
24
|
+
/** Required when this storage serves downloads through createHotUpdater. */
|
|
25
|
+
downloadUrlSigningKey?: string;
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
const
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const isS3ObjectNotFoundError = (error: unknown) => {
|
|
40
|
-
if (error instanceof Error) {
|
|
41
|
-
return error.name === "NotFound" || error.name === "NoSuchKey";
|
|
28
|
+
const isObjectNotFoundError = (error: unknown) => {
|
|
29
|
+
if (
|
|
30
|
+
error instanceof Error &&
|
|
31
|
+
(error.name === "NotFound" || error.name === "NoSuchKey")
|
|
32
|
+
) {
|
|
33
|
+
return true;
|
|
42
34
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return false;
|
|
35
|
+
return (
|
|
36
|
+
typeof error === "object" &&
|
|
37
|
+
error !== null &&
|
|
38
|
+
"$metadata" in error &&
|
|
39
|
+
(error as { $metadata?: { httpStatusCode?: number } }).$metadata
|
|
40
|
+
?.httpStatusCode === 404
|
|
41
|
+
);
|
|
52
42
|
};
|
|
53
43
|
|
|
54
|
-
const
|
|
44
|
+
export const createR2S3Storage = (
|
|
45
|
+
config: R2S3StorageConfig,
|
|
46
|
+
): StoragePluginWith<"put" | "get" | "exists" | "delete"> => {
|
|
55
47
|
const {
|
|
56
48
|
accountId,
|
|
57
|
-
basePath
|
|
58
|
-
bucketName
|
|
49
|
+
basePath,
|
|
50
|
+
bucketName,
|
|
51
|
+
downloadUrlSigningKey,
|
|
59
52
|
endpoint,
|
|
60
53
|
forcePathStyle,
|
|
61
54
|
region,
|
|
62
55
|
...s3Config
|
|
63
56
|
} = config;
|
|
64
|
-
|
|
65
|
-
return new S3Client({
|
|
57
|
+
const client = new S3Client({
|
|
66
58
|
...s3Config,
|
|
67
59
|
endpoint: endpoint ?? `https://${accountId}.r2.cloudflarestorage.com`,
|
|
68
60
|
forcePathStyle: forcePathStyle ?? true,
|
|
69
61
|
region: region ?? "auto",
|
|
70
62
|
});
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
async delete(storageUri) {
|
|
82
|
-
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
83
|
-
ensureExpectedR2Bucket(bucket, bucketName);
|
|
84
|
-
|
|
85
|
-
await client.send(
|
|
86
|
-
new DeleteObjectCommand({ Bucket: bucketName, Key: key }),
|
|
63
|
+
const getStorageKey = createStorageKeyBuilder(basePath);
|
|
64
|
+
const getDownloadUrl = downloadUrlSigningKey
|
|
65
|
+
? createStorageDownloadUrl(downloadUrlSigningKey)
|
|
66
|
+
: undefined;
|
|
67
|
+
|
|
68
|
+
const parseAndValidate = (storageUri: string) => {
|
|
69
|
+
const parsed = parseStorageUri(storageUri, "r2");
|
|
70
|
+
if (parsed.bucket !== bucketName) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Bucket name mismatch: expected "${bucketName}", but found "${parsed.bucket}".`,
|
|
87
73
|
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const ContentType = getContentType(filePath);
|
|
92
|
-
const filename = path.basename(filePath);
|
|
93
|
-
const Key = getStorageKey(key, filename);
|
|
74
|
+
}
|
|
75
|
+
return parsed;
|
|
76
|
+
};
|
|
94
77
|
|
|
95
|
-
|
|
78
|
+
return createStoragePlugin({
|
|
79
|
+
name: "r2Storage",
|
|
80
|
+
protocol: "r2",
|
|
81
|
+
async put({ key, body, contentLength, contentType }) {
|
|
82
|
+
const storageKey = getStorageKey(key);
|
|
83
|
+
await new Upload({
|
|
96
84
|
client,
|
|
97
85
|
params: {
|
|
98
|
-
Body,
|
|
86
|
+
Body: body,
|
|
99
87
|
Bucket: bucketName,
|
|
100
88
|
CacheControl: "max-age=31536000",
|
|
101
|
-
|
|
102
|
-
|
|
89
|
+
ContentLength: contentLength,
|
|
90
|
+
ContentType: contentType,
|
|
91
|
+
Key: storageKey,
|
|
103
92
|
},
|
|
104
|
-
});
|
|
105
|
-
await upload.done();
|
|
106
|
-
|
|
93
|
+
}).done();
|
|
107
94
|
return {
|
|
108
|
-
storageUri:
|
|
95
|
+
storageUri: createStorageUri({
|
|
96
|
+
protocol: "r2",
|
|
97
|
+
bucket: bucketName,
|
|
98
|
+
key: storageKey,
|
|
99
|
+
}),
|
|
109
100
|
};
|
|
110
101
|
},
|
|
111
|
-
async
|
|
112
|
-
const {
|
|
113
|
-
ensureExpectedR2Bucket(bucket, bucketName);
|
|
114
|
-
|
|
102
|
+
async get({ storageUri }) {
|
|
103
|
+
const { key } = parseAndValidate(storageUri);
|
|
115
104
|
try {
|
|
116
|
-
await client.send(
|
|
117
|
-
new
|
|
105
|
+
const response = await client.send(
|
|
106
|
+
new GetObjectCommand({ Bucket: bucketName, Key: key }),
|
|
118
107
|
);
|
|
119
|
-
return
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
108
|
+
if (!response.Body) return { response: null };
|
|
109
|
+
const headers = new Headers();
|
|
110
|
+
if (response.ContentType)
|
|
111
|
+
headers.set("content-type", response.ContentType);
|
|
112
|
+
if (response.ContentLength !== undefined) {
|
|
113
|
+
headers.set("content-length", String(response.ContentLength));
|
|
123
114
|
}
|
|
124
|
-
|
|
115
|
+
return {
|
|
116
|
+
response: new Response(response.Body.transformToWebStream(), {
|
|
117
|
+
headers,
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if (isObjectNotFoundError(error)) return { response: null };
|
|
125
122
|
throw error;
|
|
126
123
|
}
|
|
127
124
|
},
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
141
|
-
await fs.writeFile(filePath, await response.Body.transformToByteArray());
|
|
142
|
-
},
|
|
143
|
-
};
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
export const createS3RuntimeStorageProfile = (
|
|
147
|
-
config: R2S3StorageConfig,
|
|
148
|
-
): RuntimeStorageProfile => {
|
|
149
|
-
const { bucketName } = config;
|
|
150
|
-
const client = createS3Client(config);
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
async readText(storageUri) {
|
|
154
|
-
const { bucket, key } = parseStorageUri(storageUri, "r2");
|
|
155
|
-
ensureExpectedR2Bucket(bucket, bucketName);
|
|
156
|
-
|
|
125
|
+
...(getDownloadUrl
|
|
126
|
+
? {
|
|
127
|
+
async getDownloadUrl({ storageUri }: { storageUri: string }) {
|
|
128
|
+
parseAndValidate(storageUri);
|
|
129
|
+
return getDownloadUrl({ storageUri });
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
async exists({ storageUri }) {
|
|
134
|
+
const { key } = parseAndValidate(storageUri);
|
|
157
135
|
try {
|
|
158
|
-
|
|
159
|
-
new
|
|
136
|
+
await client.send(
|
|
137
|
+
new HeadObjectCommand({ Bucket: bucketName, Key: key }),
|
|
160
138
|
);
|
|
161
|
-
|
|
162
|
-
if (!response.Body) {
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return response.Body.transformToString();
|
|
139
|
+
return { exists: true };
|
|
167
140
|
} catch (error) {
|
|
168
|
-
if (
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
|
|
141
|
+
if (isObjectNotFoundError(error)) return { exists: false };
|
|
172
142
|
throw error;
|
|
173
143
|
}
|
|
174
144
|
},
|
|
175
|
-
async
|
|
176
|
-
const {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const command = new GetObjectCommand({ Bucket: bucketName, Key: key });
|
|
180
|
-
const signedUrl = await getSignedUrl(
|
|
181
|
-
client as unknown as Parameters<typeof getSignedUrl>[0],
|
|
182
|
-
command,
|
|
183
|
-
{
|
|
184
|
-
expiresIn: 3600,
|
|
185
|
-
},
|
|
145
|
+
async delete({ storageUri }) {
|
|
146
|
+
const { key } = parseAndValidate(storageUri);
|
|
147
|
+
await client.send(
|
|
148
|
+
new DeleteObjectCommand({ Bucket: bucketName, Key: key }),
|
|
186
149
|
);
|
|
187
|
-
|
|
188
|
-
if (!signedUrl) {
|
|
189
|
-
throw new Error("Failed to presign R2 URL");
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
fileUrl: signedUrl,
|
|
194
|
-
};
|
|
150
|
+
return { deleted: true };
|
|
195
151
|
},
|
|
196
|
-
};
|
|
152
|
+
});
|
|
197
153
|
};
|